Skip to content

feat: add Bun runtime support#717

Draft
killagu wants to merge 4 commits intomasterfrom
feat/bun-runtime-support
Draft

feat: add Bun runtime support#717
killagu wants to merge 4 commits intomasterfrom
feat/bun-runtime-support

Conversation

@killagu
Copy link
Copy Markdown
Member

@killagu killagu commented Mar 28, 2026

Summary

  • Enable urllib to run on the Bun runtime by adapting to differences in Bun's built-in undici implementation
  • Work around Bun limitations: timeout options ignored, Node.js streams unsupported as request body, auto-decompression, different error types, non-extensible error objects
  • Use AbortSignal.any() to compose Bun timeout signal with user-provided abort signal
  • Add FormData.toBuffer() to serialize multipart form data for Bun's undici
  • Skip tests for features Bun doesn't support (H2, diagnostics_channel, unix socket, MockAgent, rejectUnauthorized connect option)

Test results:

Runtime Test Files Tests Failures
Node.js 40 passed, 1 skipped 202 passed, 6 skipped 0
Bun 37 passed, 4 skipped 143 passed, 65 skipped 0

Test plan

  • All Node.js tests pass (40 files, 202 tests)
  • All Bun tests pass (37 files, 143 tests, 0 failures)
  • Verify on CI with both Node.js and Bun

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added support for Bun runtime with runtime-specific optimizations for request handling and multipart form data processing.
    • Added toBuffer() async method to FormData for buffer conversion.
  • Improvements

    • Enhanced error handling and timeout behavior with runtime-aware detection.
    • Optimized request body and decompression handling for different JavaScript runtimes.
  • Tests

    • Expanded test coverage with Bun-specific test conditions and assertions.
    • Added GitHub Actions workflow for continuous integration testing on Bun.

Enable urllib to run on Bun by working around differences in Bun's
built-in undici implementation:

- Use AbortSignal.timeout() for request timeouts (Bun ignores
  headersTimeout/bodyTimeout), compose with user signal via
  AbortSignal.any()
- Convert Node.js Readable streams to Buffer/Web ReadableStream
  for request bodies (Bun's undici can't consume Node.js streams)
- Skip manual decompression (Bun auto-decompresses gzip/br)
- Handle Bun-specific error types (TimeoutError, ConnectionClosed)
- Wrap non-extensible error objects via Object.isExtensible() check
- Adapt User-Agent header to show Bun version
- Add FormData.toBuffer() for Bun compatibility
- Guard fetch internals against Bun's different Response state
- Skip tests for unsupported Bun features (H2, diagnostics_channel,
  unix socket, MockAgent, rejectUnauthorized)

Node.js: 40 test files passed, 202 tests passed
Bun: 37 test files passed, 143 tests passed, 0 failures

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 28, 2026 09:12
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 28, 2026

📝 Walkthrough

Walkthrough

This PR adds comprehensive Bun runtime support to the urllib HTTP client. It introduces runtime detection, modifies request and response handling for Bun's undici-based environment (including form-data buffering and timeout management), updates the user-agent header, adds error mapping for Bun-specific errors, and conditionally skips incompatible tests. A new CI workflow tests the library against Bun on multiple platforms.

Changes

Cohort / File(s) Summary
FormData Buffer Export
src/FormData.ts
Added toBuffer() async method that collects stream chunks into a Buffer, enabling non-streaming form-data transmission on Bun.
Bun Runtime Support
src/HttpClient.ts
Implemented Bun detection, updated user-agent, modified multipart handling to buffer file streams, changed timeout/signal behavior for Bun (AbortSignal.timeout wrapping), updated response decompression logic to skip gzip/brotli on Bun, and expanded error mapping for Bun's undici timeout and socket errors.
Fetch Error Resilience
src/fetch.ts
Wrapped getResponseState() call in try/catch to prevent exceptions from blocking diagnostics publication when undici-specific response state is unavailable.
Test Runtime Conditionals
test/HttpClient.*.test.ts, test/options.*.test.ts, test/diagnostics_channel.test.ts, test/fetch.test.ts, test/head-request-should-keepalive.test.ts, test/index.test.ts, test/keep-alive-header.test.ts, test/non-ascii-request-header.test.ts, test/urllib.options.rejectUnauthorized-false.test.ts, test/user-agent.test.ts
Imported isBun runtime flag and selectively skipped tests incompatible with Bun (streams, keep-alive headers, decompression, socket diagnostics, HTTP/2, signal-as-EventEmitter) using it.skipIf(isBun) or describe.skipIf(isBun). Updated error expectations (timeout messages, socket errors) to handle runtime-specific variants.
Timeout Helper Refactor
test/options.timeout.test.ts
Extracted shared assertTimeoutError() helper to reduce duplication and introduced runtime-specific timeout value/error assertions for Bun (400) vs Node.js (500).
Bun CI Workflow
.github/workflows/bun.yml
Added GitHub Actions workflow running tests on Bun with matrix coverage (ubuntu-latest, macos-latest), including Vitest/Vite import rewrites and dependency updates before execution.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • #547: Shared modifications to FormData.ts, HttpClient.ts, and fetch.ts for undici/form-data compatibility improvements.
  • #559: Introduces WebFormData export and streaming form-data tests related to the new toBuffer() method.
  • #514: Earlier Bun runtime detection and HttpClient form-data handling updates in undici v6 migration.

Suggested labels

enhancement

Poem

🐰 With Bun now in the fold, our urllib hops so fast,
Form-data buffered, streams converted, no more Node-bound cast,
From errors mapped to timeouts tamed, each platform gets its due,
The little rabbit's toolkit now works where Bun runs true! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title 'feat: add Bun runtime support' directly and clearly summarizes the main change—adding support for the Bun runtime throughout the codebase.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/bun-runtime-support

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements compatibility for the Bun runtime by addressing limitations in Bun's undici implementation. Key changes include adding a toBuffer method to FormData, implementing a fallback timeout mechanism using AbortSignal.timeout, and adapting file and stream handling to use memory buffers or Web Streams. The test suite was also updated to skip Node.js-specific features when running in Bun. Review feedback identifies potential memory risks associated with buffering large data in Bun and suggests adjusting the reported timeout values in error messages to accurately reflect the combined headers and body timeout duration.

Comment on lines +48 to +61
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();
});
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This method buffers the entire FormData stream into memory. While the comment explains this is for Bun compatibility, it's important to be aware of the memory implications. For large forms with large files, this could lead to high memory usage and potential performance problems. This is a significant trade-off for compatibility.

Comment on lines +528 to +534
if (isBun) {
// Bun's CombinedStream can't pipe Node.js streams
const streamChunks: Buffer[] = [];
for await (const chunk of file) {
streamChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
value = Buffer.concat(streamChunks);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This implementation buffers the entire stream into memory before sending the request when running on Bun. This introduces a risk of high memory consumption for large file uploads, which could lead to performance issues or out-of-memory errors in memory-constrained environments.

Comment on lines +818 to +821
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 });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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 });

Run tests on Bun runtime across ubuntu and macos. Patches vitest
imports from vite-plus to vanilla vitest since Bun can't use the
vite-plus wrapper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds Bun runtime compatibility to urllib by adapting request/response handling to Bun’s built-in undici differences, while conditionally skipping/loosening tests for features Bun doesn’t support yet.

Changes:

  • Add Bun runtime detection (isBun) and adjust default User-Agent to include Bun version.
  • Implement Bun-specific request handling: signal-based timeout fallback, request-body conversions for FormData/streams, and skip manual decompression.
  • Update test suite to skip or relax assertions for Bun-incompatible undici features (H2, unix sockets, diagnostics_channel, MockAgent, etc.).

Reviewed changes

Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/HttpClient.ts Core Bun runtime adaptations (UA, timeout fallback signal, body conversions, decompression behavior, error wrapping).
src/FormData.ts Adds FormData.toBuffer() to serialize multipart bodies for Bun compatibility.
src/fetch.ts Makes fetch diagnostics more tolerant when Bun’s Response doesn’t match undici internal state.
test/user-agent.test.ts Accept Bun User-Agent; skip UA-empty/undefined tests on Bun.
test/urllib.options.rejectUnauthorized-false.test.ts Skip Bun where undici Agent lacks rejectUnauthorized connect option and H2.
test/options.writeStream.test.ts Skip Bun cases impacted by compression + timeout/writeStream behaviors.
test/options.timing.test.ts Skip Bun timing assertions (timing not populated).
test/options.timeout.test.ts Adjust timeout assertions to tolerate Bun error types/wrapping; skip H2 timeout test on Bun.
test/options.streaming.test.ts Skip Bun compressed streaming test; skip large (1GB) streaming test due to Bun slowness/timeouts.
test/options.stream.test.ts Skip Bun stream-timeout and stream-error behaviors.
test/options.socketPath.test.ts Skip Bun (unix socket support not available/compatible).
test/options.socketErrorRetry.test.ts Relax socket error retry expectations; skip Bun retry behavior tests.
test/options.signal.test.ts Relax abort error message match; skip Bun EventEmitter-based abort.
test/options.reset.test.ts Skip reset/connection-close assertion on Bun.
test/options.opaque.test.ts Skip Bun opaque-on-error test.
test/options.headers.test.ts Skip Bun header normalization/default-header behavior assertions.
test/options.gzip.test.ts Skip Bun gzip/br behavior tests (auto-decompression differences).
test/options.followRedirect.test.ts Skip Bun “disable auto redirect” behavior test.
test/options.fixJSONCtlChars.test.ts Skip Bun invalid JSON control-char test.
test/options.dataType.test.ts Skip Bun invalid JSON format assertion test.
test/options.data.test.ts Skip Bun cases involving preserving Readable request bodies.
test/options.content.test.ts Skip Bun POST with Readable content body.
test/options.compressed.test.ts Skip Bun compressed default/invalid compressed content tests.
test/non-ascii-request-header.test.ts Accept Bun’s TypeError for invalid header instead of undici InvalidArgumentError.
test/keep-alive-header.test.ts Skip keep-alive header assertions on Bun (headers stripped internally).
test/index.test.ts Skip Bun set-cookie string and socket-end error tests; skip mocking suite on Bun.
test/head-request-should-keepalive.test.ts Skip Bun connection header + socket reuse assertions and reset expectations.
test/formData-with-BufferStream.test.ts Adds isBun import (Bun-specific behavior coverage setup).
test/fetch.test.ts Skip Bun fetch diagnostics tests.
test/diagnostics_channel.test.ts Skip entire suite on Bun (diagnostics_channel limitations).
test/HttpClient.test.ts Skip Bun suites for allowH2/lookup/checkAddress.
test/HttpClient.events.test.ts Adjust expectations for timing/socket/error fields under Bun.
test/HttpClient.connect.rejectUnauthorized.test.ts Skip Bun rejectUnauthorized connect option test.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

headers['content-type'] = 'text/plain;charset=UTF-8';
}
isStreamingRequest = isReadable(args.content);
isStreamingRequest = !isBun && isReadable(args.content);
Copy link

Copilot AI Mar 28, 2026

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.

Suggested change
isStreamingRequest = !isBun && isReadable(args.content);
isStreamingRequest = isReadable(args.content);

Copilot uses AI. Check for mistakes.
if (isBun) {
// Bun's CombinedStream can't pipe Node.js streams
const streamChunks: Buffer[] = [];
for await (const chunk of file) {
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the Bun path for args.files, you buffer non-Buffer inputs by iterating with for await (const chunk of file). This will throw if file is only “readable-like” (passes isReadable) but is not actually async-iterable. To avoid runtime errors for legacy/old-style streams, wrap non-Readable streams into a real Readable (e.g., via new Readable().wrap(...)) or check for Symbol.asyncIterator before using for await.

Suggested change
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) {

Copilot uses AI. Check for mistakes.
Comment on lines +51 to +56
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);
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FormData.toBuffer() attaches data/end/error listeners with .on(...) but never removes them. If the same FormData instance is re-used or toBuffer() is called more than once, listeners can accumulate and cause leaks or duplicate resolution attempts. Prefer once(...) handlers and/or explicitly remove listeners in both resolve and reject paths.

Suggested change
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);
this.once('data', (chunk: Buffer | string) => {
// CombinedStream emits boundary/header strings alongside Buffer data
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
});
this.once('end', () => resolve(Buffer.concat(chunks)));
this.once('error', reject);

Copilot uses AI. Check for mistakes.
Comment on lines +250 to +251
} catch {
state = {};
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This try/catch around getResponseState(res) is unconditional, so any unexpected failure in Node (e.g., undici internal API changes) will now be silently swallowed and timingInfo will be dropped without any signal. To keep Node behavior fail-fast while still supporting Bun, consider gating the fallback to Bun only (or catching only the expected error type and logging/debugging when it happens).

Suggested change
} 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;
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (13)
test/options.opaque.test.ts (1)

35-53: Avoid fully skipping opaque-on-error coverage for Bun.

Even if Bun emits different error names/messages, this test should still assert that err.opaque is preserved. Consider runtime-conditional assertions instead of skipping the case entirely.

Suggested adjustment
-  it.skipIf(isBun)('should opaque work on error request', async () => {
+  it('should opaque work on error request', async () => {
     await assert.rejects(
       async () => {
         await urllib.request(`${_url}socket.end.error`, {
           opaque: {
             traceId: 'some random id here',
           },
         });
       },
       (err: any) => {
-        assert.equal(err.res.status, 200);
-        assert.equal(err.name, 'HTTPParserError');
+        if (!isBun) {
+          assert.equal(err.res.status, 200);
+          assert.equal(err.name, 'HTTPParserError');
+        }
         assert.deepEqual(err.opaque, {
           traceId: 'some random id here',
         });
         return true;
       },
     );
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/options.opaque.test.ts` around lines 35 - 53, Remove the full skip for
Bun and ensure the test always verifies that the opaque object is preserved on
error: change the skipped test (the it.skipIf(isBun) block around the 'should
opaque work on error request' case) to run unconditionally and inside the
assert.rejects callback keep the check assert.deepEqual(err.opaque, { traceId:
'some random id here' }) but make the assertion on err.name/runtime-specific
error conditional (e.g. if (isBun) { assert.ok(['SomeBunErrorName',
'HTTPParserError'].includes(err.name)) } else { assert.equal(err.name,
'HTTPParserError') }), keeping the other checks on err.res.status and using the
same urllib.request call with opaque to locate the code.
test/options.timing.test.ts (1)

24-84: Add a minimal Bun timing contract test if detailed timings are runtime-specific.

If exact phase timings are unstable on Bun, keep at least a lightweight assertion path (timing object exists, fields are non-negative, rt > 0) instead of skipping both positive-timing cases.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/options.timing.test.ts` around lines 24 - 84, The two skipped tests
('should timing = true work' and 'should timing default to true' in
test/options.timing.test.ts) lose Bun coverage; update them so when isBun is
true they perform a minimal Bun-friendly contract: request with timing: true (or
default) and assert response.status === 200, res is RawResponseWithMeta,
res.timing exists, each timing field (e.g., waiting, queuing, connected,
requestHeadersSent, requestSent, contentDownload) is >= 0 (not > 0), and res.rt
> 0; keep the existing stricter assertions for non-Bun runtimes (isBun false)
inside the same test by branching on isBun so you don't fully skip the tests.
test/options.content.test.ts (1)

166-184: Keep a Bun assertion path for readable content uploads.

Since Bun stream-body adaptation is part of this PR, fully skipping this case drops important coverage. Keep the test and relax Bun-specific header assertions if needed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/options.content.test.ts` around lines 166 - 184, The test "should POST
content = readable" was fully skipped for Bun but we need to keep it and relax
Bun-specific header assertions: remove the overall skipIf(isBun) and instead run
the test for all runtimes, and inside the test guard the header assertions using
the isBun flag (or equivalent) so that when isBun is true you skip or adjust
checks for 'transfer-encoding' and 'connection' headers while keeping all other
assertions (status, data length/content equality, x-request-headers parsing,
URL/redirect checks) intact; locate the test block (the it.skipIf/isBun
wrapper), the urllib.request call, and the assertions for
requestHeaders['transfer-encoding'] and requestHeaders.connection to apply the
conditional checks.
test/options.writeStream.test.ts (1)

52-159: Consider Bun-specific assertions instead of skipping all writeStream edge cases.

These tests protect important invariants (writeStream close/end behavior on timeout/error). Prefer keeping them active with Bun-adjusted expectations rather than blanket skips.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/options.writeStream.test.ts` around lines 52 - 159, Tests currently skip
writeStream edge cases on Bun; instead remove skipIf(isBun) and make
Bun-specific assertions so behavior is validated across runtimes. For each test
named 'should work with compressed=true/false', 'should close writeStream when
request timeout', 'should throw request error when writeStream error', and
'should end writeStream when server error' replace the top-level skip with
runtime branches using the isBun flag: keep the same setup (createWriteStream,
urllib.request) but assert alternative expectations when isBun is true (e.g.
different destroyed/close/error semantics) and keep the original assertions for
non-Bun. Reference symbols: createWriteStream, urllib.request,
writeStream.destroyed, writeStream.on('close'), writeStream.on('error'),
writeStreamClosed, writeStreamError, and the existing process.version v16 check;
implement concise if (isBun) { /* Bun expectations */ } else { /* original
asserts */ } blocks in each test.
test/options.gzip.test.ts (2)

100-100: Minor typo: "event" should be "even" in test description.

✏️ Fix typo
-  it.skipIf(isBun)('should use compressed = false event gzip = true', async () => {
+  it.skipIf(isBun)('should use compressed = false even when gzip = true', async () => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/options.gzip.test.ts` at line 100, The test description string for the
skipped test uses a typo: "event" should be "even"; update the test title in the
it.skipIf(isBun) call (the string 'should use compressed = false event gzip =
true') to read 'should use compressed = false even gzip = true' so the
human-readable test name is correct (locate the it.skipIf(isBun) test
declaration in test/options.gzip.test.ts and change the quoted description).

35-35: Add comments explaining why these compression tests are skipped on Bun.

Based on src/HttpClient.ts:704-718, Bun's undici auto-decompresses response bodies transparently. Tests with gzip=false or compressed=false verify manual decompression control, which is inapplicable when Bun handles decompression automatically.

💡 Suggested comments
+  // Bun: auto-decompresses responses, can't test disabled decompression
   it.skipIf(isBun)('should handle gzip text response on gzip = false', async () => {
+  // Bun: auto-decompresses responses, can't test disabled decompression
   it.skipIf(isBun)('should handle gzip json response on gzip = false', async () => {
+  // Bun: auto-decompresses responses, can't test disabled decompression
   it.skipIf(isBun)('should handle br json response on gzip = false', async () => {
+  // Bun: auto-decompresses responses, compressed=false has no effect
   it.skipIf(isBun)('should use compressed = false event gzip = true', async () => {

Also applies to: 61-61, 87-87, 100-100

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/options.gzip.test.ts` at line 35, Add a short comment above the skipped
tests (those using isBun and patterns like "should handle gzip text response on
gzip = false" and other skipped cases) explaining that Bun's undici
auto-decompresses response bodies so tests that assert behavior when gzip=false
or compressed=false (manual decompression control) are not applicable; reference
the HttpClient behavior in src/HttpClient.ts around the undici
auto-decompression logic (lines ~704-718) so future readers know why these tests
are skipped on Bun.
test/options.data.test.ts (1)

505-505: Add comments explaining why these readable stream tests are skipped on Bun.

Per the PR description, Bun's undici does not consume Node.js Readable streams directly. These tests use Readable.from() which would fail on Bun without the stream-to-buffer conversion that HttpClient.ts implements for request bodies.

💡 Suggested comments
+  // Bun: Node.js Readable streams as request body require conversion
   it.skipIf(isBun)('should keep data to readable when content-type exists', async () => {
+  // Bun: Node.js Readable streams as request body require conversion
   it.skipIf(isBun)('should keep data to readable and not set content-type', async () => {

Also applies to: 531-531

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/options.data.test.ts` at line 505, The skipped tests "should keep data
to readable when content-type exists" and the similar one at the other location
are missing explanatory comments; update the test file test/options.data.test.ts
by adding brief comments above the it.skipIf(isBun) blocks that state Bun's
undici does not consume Node.js Readable streams (e.g. Readable.from()), so
these tests rely on HttpClient.ts's stream-to-buffer conversion and will fail on
Bun without it—mention Readable.from() and HttpClient.ts behavior so future
readers understand why the skip is necessary.
test/options.streaming.test.ts (1)

58-58: Add a comment explaining why this test is skipped on Bun.

Line 141 has a helpful comment (// Bun: 1GB streaming transfer too slow...), but line 58 lacks explanation. Based on src/HttpClient.ts:704-718, Bun auto-decompresses response bodies, making the compressed=true/false behavior assertions invalid on Bun.

💡 Suggested comment
-  it.skipIf(isBun)('should work on streaming=true and compressed=true/false', async () => {
+  // Bun: auto-decompresses responses, so manual decompression assertions don't apply
+  it.skipIf(isBun)('should work on streaming=true and compressed=true/false', async () => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/options.streaming.test.ts` at line 58, Add an inline comment next to the
skipped test declaration (the it.skipIf(isBun) for 'should work on
streaming=true and compressed=true/false') explaining that the test is skipped
on Bun because Bun auto-decompresses response bodies (see HttpClient behavior
around src/HttpClient.ts:704-718), which invalidates assertions about
compressed=true/false; mention that this causes streaming/compressed behavior
differences on Bun and reference isBun as the guard.
test/HttpClient.test.ts (1)

57-57: Consider adding brief comments for skipped describe blocks.

While the PR description documents that HTTP/2, custom DNS lookup, and checkAddress aren't supported on Bun, inline comments would help future maintainers quickly understand why these entire test suites are skipped.

💡 Suggested comments
+  // Bun's undici doesn't support HTTP/2
   describe.skipIf(isBun)('clientOptions.allowH2', () => {
+  // Bun's undici doesn't support custom DNS lookup
   describe.skipIf(isBun)('clientOptions.lookup', () => {
+  // Bun's undici doesn't support checkAddress for SSRF protection
   describe.skipIf(isBun)('clientOptions.checkAddress', () => {

Also applies to: 259-259, 310-310

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/HttpClient.test.ts` at line 57, Add a brief inline comment above each
describe.skipIf(isBun) block explaining why the suite is skipped on Bun (e.g.,
"Bun does not support HTTP/2, custom DNS lookup, or socket checkAddress, so
these tests are skipped on Bun"). Specifically update the
describe.skipIf(isBun)('clientOptions.allowH2') block and the other skipped
describe blocks for custom DNS lookup and checkAddress to include that concise
rationale so future maintainers understand the reason at a glance.
test/options.stream.test.ts (1)

107-107: Add skip comments explaining why these tests are skipped on Bun.

Other skipped tests in this PR include brief comments (e.g., lines 27-28 in urllib.options.rejectUnauthorized-false.test.ts). These three skipped tests lack explanatory comments, making it harder for future maintainers to understand the Bun-specific limitations.

💡 Suggested comments
-  it.skipIf(isBun)('should close 1KB request stream when request timeout', async () => {
+  // Bun: timeout handling differs for stream requests
+  it.skipIf(isBun)('should close 1KB request stream when request timeout', async () => {
-  it.skipIf(isBun)('should close 10MB size request stream when request timeout', async () => {
+  // Bun: timeout handling differs for stream requests
+  it.skipIf(isBun)('should close 10MB size request stream when request timeout', async () => {
-  it.skipIf(isBun)('should throw request error when stream error', async () => {
+  // Bun: stream error propagation differs from Node.js
+  it.skipIf(isBun)('should throw request error when stream error', async () => {

Also applies to: 132-132, 157-157

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/options.stream.test.ts` at line 107, Add brief explanatory comments
above each it.skipIf(isBun) test in test/options.stream.test.ts (the tests with
descriptions "should close 1KB request stream when request timeout" and the
other two at the same pattern) that explain why they are skipped on Bun (e.g.,
missing stream timeout behavior, platform-specific socket handling, or known Bun
bug/limitation), mirroring the style used in
urllib.options.rejectUnauthorized-false.test.ts; place the comment immediately
above the it.skipIf call so future maintainers can quickly see the Bun-specific
reason and any issue/PR reference if available.
test/index.test.ts (1)

57-57: Add comments explaining why these tests/suites are skipped on Bun.

The PR description mentions MockAgent is unsupported, but the other two skips (set-cookie string handling and socket.end error handling) lack documentation explaining the Bun-specific differences.

💡 Suggested comments
+    // Bun: set-cookie header handling differs from Node.js undici
     it.skipIf(isBun)('should response set-cookie as a string', async () => {
+    // Bun: HTTP parser error handling differs from Node.js
     it.skipIf(isBun)('should handle server socket end("balabal") will error', async () => {
+  // Bun: MockAgent is not supported
   describe.skipIf(isBun)('Mocking request', () => {

Also applies to: 149-149, 191-191

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/index.test.ts` at line 57, Three tests are being skipped on Bun without
explanations; add brief inline comments above each skipped test (the
it.skipIf(isBun) blocks for "should response set-cookie as a string", the other
set-cookie test, and the socket.end error handling test) explaining the
Bun-specific reason (e.g., MockAgent unsupported on Bun and any behavior
differences in set-cookie string handling or socket.end error semantics) so
reviewers know why these are skipped; reference the test names ("should response
set-cookie as a string", the other set-cookie test, and the socket.end error
handling test) and update the test file to include a one-line comment per
skipped test describing the exact Bun limitation or behavioral divergence.
test/options.reset.test.ts (1)

29-31: Add a Bun-side assertion to avoid a no-op branch.

At Line [29], Bun currently skips the connection check entirely. Consider asserting that the header is not 'close' (or is absent) so this branch still validates expected Bun behavior.

Proposed test tightening
-    if (!isBun) {
-      assert(response.data.headers.connection === 'close');
-    }
+    if (isBun) {
+      assert.notEqual(response.data.headers.connection, 'close');
+    } else {
+      assert.equal(response.data.headers.connection, 'close');
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/options.reset.test.ts` around lines 29 - 31, The test currently only
asserts connection === 'close' inside the if (!isBun) branch, leaving Bun as a
no-op; update the test in options.reset.test.ts to add an else branch for when
isBun is true that asserts the response.data.headers.connection is not 'close'
(or is undefined/null) so the Bun behavior is validated; locate the isBun check
and response.data.headers.connection usage and add the complementary assertion
in the else branch to tighten the test.
test/HttpClient.events.test.ts (1)

113-119: Bun error assertions are currently too permissive.

Line [115] and Line [137] only check truthiness of name, which won’t catch many regressions. Prefer stable-but-strong invariants (name/message are strings, and error metadata is present).

Proposed assertion strengthening
       if (isBun) {
         // Bun throws different error types for socket errors
-        assert(info.error.name);
+        assert.equal(typeof info.error.name, 'string');
+        assert.equal(typeof info.error.message, 'string');
       } else {
         assert.equal(info.error.name, 'SocketError');
         assert.equal(info.error.message, 'other side closed');
       }
@@
         assert(err.status === -1 || err.status === undefined);
         if (isBun) {
-          assert(err.name);
+          assert.equal(typeof err.name, 'string');
+          assert.equal(typeof err.message, 'string');
+          assert(err.res);
         } else {
           assert(err.res);
           assert.equal(err.name, 'SocketError');
           assert.equal(err.message, 'other side closed');
         }

Also applies to: 136-142

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/HttpClient.events.test.ts` around lines 113 - 119, The Bun-specific
assertions in the test rely only on truthiness (isBun branch) and are too weak;
update the checks around the info object (variable info) and the isBun branch to
assert stable invariants instead of just presence: verify that info.error
exists, that typeof info.error.name === 'string' and typeof info.error.message
=== 'string', and that any expected metadata fields (e.g., code or errno if
present) are present or of the correct type; apply the same strengthened
assertions to the other Bun branch later in the file so both occurrences
validate string types for name/message and required metadata rather than mere
truthiness.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/HttpClient.ts`:
- Around line 117-126: The User-Agent construction currently swaps the canonical
"Node.js/..." token for "Bun/..." when running in Bun; change getRuntimeInfo so
it always returns the canonical Node.js token (e.g.
`Node.js/${process.version.substring(1)}`) and adjust HEADER_USER_AGENT to
append a separate Bun token only when running under Bun (e.g. add `
Bun/${process.versions.bun}` when isBun is true) so HEADER_USER_AGENT preserves
`node-urllib/${VERSION} Node.js/{version} (...)` and surfaces Bun separately;
update references in getRuntimeInfo and HEADER_USER_AGENT (and use VERSION)
accordingly.
- Around line 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).
- Around line 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).
- 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.
- Around line 521-523: The code currently forces Bun multipart streams into
memory via readFile / Buffer.concat / toBuffer which can OOM for large uploads;
update the HttpClient upload handling (check isBun and the multipart file/stream
branches in the upload/multipart logic) to avoid silently materializing streams:
detect when a file input is a Readable/stream and on Bun either (a) throw a
clear, documented error rejecting streaming uploads on Bun with guidance to
provide a file path or a Buffer, or (b) enforce a configurable size cap (e.g.,
maxUploadBytes) and reject if the stream exceeds it; do not call readFile or
Buffer.concat unconditionally for stream inputs—either require file paths (use
createReadStream) or write to a temporary file on disk with streaming I/O before
building the multipart body so backpressure is preserved; ensure the change
touches the isBun/readFile/createReadStream/toBuffer/Buffer.concat code paths
used for multipart handling.
- Around line 443-448: The build fails because AbortSignal.any() only accepts
AbortSignal, but args.signal can be an EventEmitter; update the Bun-specific
block that creates requestSignal (around isBun, AbortSignal.timeout,
headersTimeout, bodyTimeout) to first narrow args.signal to an AbortSignal
(e.g., check typeof/instance or a type guard) and only call
AbortSignal.any([bunTimeoutSignal, args.signal]) when args.signal is an
AbortSignal; otherwise handle the EventEmitter case separately (for example, use
bunTimeoutSignal alone or convert/subscribe the EventEmitter without passing it
into AbortSignal.any).

In `@test/options.socketErrorRetry.test.ts`:
- Around line 45-50: The Bun branch in the test uses a non-strict assertion
(assert(err.res.socketErrorRetries >= 0)) which won't catch regressions; tighten
it to validate retry behavior by asserting a meaningful minimum or exact value
for err.res.socketErrorRetries when isBun is true (e.g.,
assert(err.res.socketErrorRetries >= 1) or
assert.equal(err.res.socketErrorRetries, expectedBunRetries)), updating the test
in options.socketErrorRetry.test.ts to use that stricter check alongside the
existing non-Bun assert.equal path so the socket retry behavior for the isBun
branch is actually verified.

---

Nitpick comments:
In `@test/HttpClient.events.test.ts`:
- Around line 113-119: The Bun-specific assertions in the test rely only on
truthiness (isBun branch) and are too weak; update the checks around the info
object (variable info) and the isBun branch to assert stable invariants instead
of just presence: verify that info.error exists, that typeof info.error.name ===
'string' and typeof info.error.message === 'string', and that any expected
metadata fields (e.g., code or errno if present) are present or of the correct
type; apply the same strengthened assertions to the other Bun branch later in
the file so both occurrences validate string types for name/message and required
metadata rather than mere truthiness.

In `@test/HttpClient.test.ts`:
- Line 57: Add a brief inline comment above each describe.skipIf(isBun) block
explaining why the suite is skipped on Bun (e.g., "Bun does not support HTTP/2,
custom DNS lookup, or socket checkAddress, so these tests are skipped on Bun").
Specifically update the describe.skipIf(isBun)('clientOptions.allowH2') block
and the other skipped describe blocks for custom DNS lookup and checkAddress to
include that concise rationale so future maintainers understand the reason at a
glance.

In `@test/index.test.ts`:
- Line 57: Three tests are being skipped on Bun without explanations; add brief
inline comments above each skipped test (the it.skipIf(isBun) blocks for "should
response set-cookie as a string", the other set-cookie test, and the socket.end
error handling test) explaining the Bun-specific reason (e.g., MockAgent
unsupported on Bun and any behavior differences in set-cookie string handling or
socket.end error semantics) so reviewers know why these are skipped; reference
the test names ("should response set-cookie as a string", the other set-cookie
test, and the socket.end error handling test) and update the test file to
include a one-line comment per skipped test describing the exact Bun limitation
or behavioral divergence.

In `@test/options.content.test.ts`:
- Around line 166-184: The test "should POST content = readable" was fully
skipped for Bun but we need to keep it and relax Bun-specific header assertions:
remove the overall skipIf(isBun) and instead run the test for all runtimes, and
inside the test guard the header assertions using the isBun flag (or equivalent)
so that when isBun is true you skip or adjust checks for 'transfer-encoding' and
'connection' headers while keeping all other assertions (status, data
length/content equality, x-request-headers parsing, URL/redirect checks) intact;
locate the test block (the it.skipIf/isBun wrapper), the urllib.request call,
and the assertions for requestHeaders['transfer-encoding'] and
requestHeaders.connection to apply the conditional checks.

In `@test/options.data.test.ts`:
- Line 505: The skipped tests "should keep data to readable when content-type
exists" and the similar one at the other location are missing explanatory
comments; update the test file test/options.data.test.ts by adding brief
comments above the it.skipIf(isBun) blocks that state Bun's undici does not
consume Node.js Readable streams (e.g. Readable.from()), so these tests rely on
HttpClient.ts's stream-to-buffer conversion and will fail on Bun without
it—mention Readable.from() and HttpClient.ts behavior so future readers
understand why the skip is necessary.

In `@test/options.gzip.test.ts`:
- Line 100: The test description string for the skipped test uses a typo:
"event" should be "even"; update the test title in the it.skipIf(isBun) call
(the string 'should use compressed = false event gzip = true') to read 'should
use compressed = false even gzip = true' so the human-readable test name is
correct (locate the it.skipIf(isBun) test declaration in
test/options.gzip.test.ts and change the quoted description).
- Line 35: Add a short comment above the skipped tests (those using isBun and
patterns like "should handle gzip text response on gzip = false" and other
skipped cases) explaining that Bun's undici auto-decompresses response bodies so
tests that assert behavior when gzip=false or compressed=false (manual
decompression control) are not applicable; reference the HttpClient behavior in
src/HttpClient.ts around the undici auto-decompression logic (lines ~704-718) so
future readers know why these tests are skipped on Bun.

In `@test/options.opaque.test.ts`:
- Around line 35-53: Remove the full skip for Bun and ensure the test always
verifies that the opaque object is preserved on error: change the skipped test
(the it.skipIf(isBun) block around the 'should opaque work on error request'
case) to run unconditionally and inside the assert.rejects callback keep the
check assert.deepEqual(err.opaque, { traceId: 'some random id here' }) but make
the assertion on err.name/runtime-specific error conditional (e.g. if (isBun) {
assert.ok(['SomeBunErrorName', 'HTTPParserError'].includes(err.name)) } else {
assert.equal(err.name, 'HTTPParserError') }), keeping the other checks on
err.res.status and using the same urllib.request call with opaque to locate the
code.

In `@test/options.reset.test.ts`:
- Around line 29-31: The test currently only asserts connection === 'close'
inside the if (!isBun) branch, leaving Bun as a no-op; update the test in
options.reset.test.ts to add an else branch for when isBun is true that asserts
the response.data.headers.connection is not 'close' (or is undefined/null) so
the Bun behavior is validated; locate the isBun check and
response.data.headers.connection usage and add the complementary assertion in
the else branch to tighten the test.

In `@test/options.stream.test.ts`:
- Line 107: Add brief explanatory comments above each it.skipIf(isBun) test in
test/options.stream.test.ts (the tests with descriptions "should close 1KB
request stream when request timeout" and the other two at the same pattern) that
explain why they are skipped on Bun (e.g., missing stream timeout behavior,
platform-specific socket handling, or known Bun bug/limitation), mirroring the
style used in urllib.options.rejectUnauthorized-false.test.ts; place the comment
immediately above the it.skipIf call so future maintainers can quickly see the
Bun-specific reason and any issue/PR reference if available.

In `@test/options.streaming.test.ts`:
- Line 58: Add an inline comment next to the skipped test declaration (the
it.skipIf(isBun) for 'should work on streaming=true and compressed=true/false')
explaining that the test is skipped on Bun because Bun auto-decompresses
response bodies (see HttpClient behavior around src/HttpClient.ts:704-718),
which invalidates assertions about compressed=true/false; mention that this
causes streaming/compressed behavior differences on Bun and reference isBun as
the guard.

In `@test/options.timing.test.ts`:
- Around line 24-84: The two skipped tests ('should timing = true work' and
'should timing default to true' in test/options.timing.test.ts) lose Bun
coverage; update them so when isBun is true they perform a minimal Bun-friendly
contract: request with timing: true (or default) and assert response.status ===
200, res is RawResponseWithMeta, res.timing exists, each timing field (e.g.,
waiting, queuing, connected, requestHeadersSent, requestSent, contentDownload)
is >= 0 (not > 0), and res.rt > 0; keep the existing stricter assertions for
non-Bun runtimes (isBun false) inside the same test by branching on isBun so you
don't fully skip the tests.

In `@test/options.writeStream.test.ts`:
- Around line 52-159: Tests currently skip writeStream edge cases on Bun;
instead remove skipIf(isBun) and make Bun-specific assertions so behavior is
validated across runtimes. For each test named 'should work with
compressed=true/false', 'should close writeStream when request timeout', 'should
throw request error when writeStream error', and 'should end writeStream when
server error' replace the top-level skip with runtime branches using the isBun
flag: keep the same setup (createWriteStream, urllib.request) but assert
alternative expectations when isBun is true (e.g. different
destroyed/close/error semantics) and keep the original assertions for non-Bun.
Reference symbols: createWriteStream, urllib.request, writeStream.destroyed,
writeStream.on('close'), writeStream.on('error'), writeStreamClosed,
writeStreamError, and the existing process.version v16 check; implement concise
if (isBun) { /* Bun expectations */ } else { /* original asserts */ } blocks in
each test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 537989a4-5322-494c-8490-00d3ec246e18

📥 Commits

Reviewing files that changed from the base of the PR and between 020eaa3 and 1a37fde.

📒 Files selected for processing (33)
  • src/FormData.ts
  • src/HttpClient.ts
  • src/fetch.ts
  • test/HttpClient.connect.rejectUnauthorized.test.ts
  • test/HttpClient.events.test.ts
  • test/HttpClient.test.ts
  • test/diagnostics_channel.test.ts
  • test/fetch.test.ts
  • test/formData-with-BufferStream.test.ts
  • test/head-request-should-keepalive.test.ts
  • test/index.test.ts
  • test/keep-alive-header.test.ts
  • test/non-ascii-request-header.test.ts
  • test/options.compressed.test.ts
  • test/options.content.test.ts
  • test/options.data.test.ts
  • test/options.dataType.test.ts
  • test/options.fixJSONCtlChars.test.ts
  • test/options.followRedirect.test.ts
  • test/options.gzip.test.ts
  • test/options.headers.test.ts
  • test/options.opaque.test.ts
  • test/options.reset.test.ts
  • test/options.signal.test.ts
  • test/options.socketErrorRetry.test.ts
  • test/options.socketPath.test.ts
  • test/options.stream.test.ts
  • test/options.streaming.test.ts
  • test/options.timeout.test.ts
  • test/options.timing.test.ts
  • test/options.writeStream.test.ts
  • test/urllib.options.rejectUnauthorized-false.test.ts
  • test/user-agent.test.ts

Comment on lines +117 to +126
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})`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep the canonical Node.js/... User-Agent token.

This replaces the repo-wide Node.js/<version> token with Bun/<version>, which is a behavior change for servers/tests that parse the existing UA format. If Bun needs to be surfaced, add it separately instead of changing the canonical value.

As per coding guidelines, **/*.ts: Set User-Agent header to node-urllib/{version} Node.js/{version} ({platform}; {arch}).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/HttpClient.ts` around lines 117 - 126, The User-Agent construction
currently swaps the canonical "Node.js/..." token for "Bun/..." when running in
Bun; change getRuntimeInfo so it always returns the canonical Node.js token
(e.g. `Node.js/${process.version.substring(1)}`) and adjust HEADER_USER_AGENT to
append a separate Bun token only when running under Bun (e.g. add `
Bun/${process.versions.bun}` when isBun is true) so HEADER_USER_AGENT preserves
`node-urllib/${VERSION} Node.js/{version} (...)` and surfaces Bun separately;
update references in getRuntimeInfo and HEADER_USER_AGENT (and use VERSION)
accordingly.

Comment on lines +443 to +448
let requestSignal = args.signal;
if (isBun) {
const bunTimeoutSignal = AbortSignal.timeout(headersTimeout + bodyTimeout);
requestSignal = args.signal
? AbortSignal.any([bunTimeoutSignal, args.signal])
: bunTimeoutSignal;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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.ts

Repository: node-modules/urllib

Length of output: 546


AbortSignal.any() is blocking the build—type incompatibility at line 447.

Line 447 passes args.signal directly to AbortSignal.any(), but RequestOptions.signal is typed as AbortSignal | EventEmitter. The AbortSignal.any() method only accepts AbortSignal instances, not EventEmitter. When an EventEmitter is provided, TypeScript fails with TS2740. Narrow the signal type to AbortSignal before composing it with the timeout signal, or handle the EventEmitter case separately.

🧰 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
Verify each finding against the current code and only fix it if needed.

In `@src/HttpClient.ts` around lines 443 - 448, The build fails because
AbortSignal.any() only accepts AbortSignal, but args.signal can be an
EventEmitter; update the Bun-specific block that creates requestSignal (around
isBun, AbortSignal.timeout, headersTimeout, bodyTimeout) to first narrow
args.signal to an AbortSignal (e.g., check typeof/instance or a type guard) and
only call AbortSignal.any([bunTimeoutSignal, args.signal]) when args.signal is
an AbortSignal; otherwise handle the EventEmitter case separately (for example,
use bunTimeoutSignal alone or convert/subscribe the EventEmitter without passing
it into AbortSignal.any).

Comment on lines +521 to 523
// Bun's CombinedStream can't pipe file streams
value = isBun ? await readFile(file) : createReadStream(file);
} else if (Buffer.isBuffer(file)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Please don't silently materialize every Bun multipart upload in memory.

This path reads file inputs with readFile(), drains readable uploads into Buffer.concat(), and then materializes the full multipart body via toBuffer(). Large uploads lose streaming backpressure and can OOM the process. A safer fallback is to reject unsupported Bun streaming uploads or enforce a size cap instead of buffering unboundedly.

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
Verify each finding against the current code and only fix it if needed.

In `@src/HttpClient.ts` around lines 521 - 523, The code currently forces Bun
multipart streams into memory via readFile / Buffer.concat / toBuffer which can
OOM for large uploads; update the HttpClient upload handling (check isBun and
the multipart file/stream branches in the upload/multipart logic) to avoid
silently materializing streams: detect when a file input is a Readable/stream
and on Bun either (a) throw a clear, documented error rejecting streaming
uploads on Bun with guidance to provide a file path or a Buffer, or (b) enforce
a configurable size cap (e.g., maxUploadBytes) and reject if the stream exceeds
it; do not call readFile or Buffer.concat unconditionally for stream
inputs—either require file paths (use createReadStream) or write to a temporary
file on disk with streaming I/O before building the multipart body so
backpressure is preserved; ensure the change touches the
isBun/readFile/createReadStream/toBuffer/Buffer.concat code paths used for
multipart handling.

Comment on lines +557 to 563
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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Suggested change
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).

headers['content-type'] = 'text/plain;charset=UTF-8';
}
isStreamingRequest = isReadable(args.content);
isStreamingRequest = !isBun && isReadable(args.content);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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);
As per coding guidelines, `**/*.ts`: Disable retry and redirect functionality for streaming requests.

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.

Comment on lines +816 to +821
} else if (isBun && err.name === 'TimeoutError') {
// Bun's undici throws TimeoutError instead of HeadersTimeoutError/BodyTimeoutError
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 });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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).

Comment on lines +45 to +50
if (isBun) {
// Bun's socket error code differs, retry may not trigger
assert(err.res.socketErrorRetries >= 0);
} else {
assert.equal(err.res.socketErrorRetries, 1);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Bun retry assertion is too weak to detect regressions.

At Line [47], assert(err.res.socketErrorRetries >= 0) is effectively non-validating. Please constrain the expected range so the test still verifies retry behavior.

Proposed assertion tightening
         if (isBun) {
           // Bun's socket error code differs, retry may not trigger
-          assert(err.res.socketErrorRetries >= 0);
+          assert.equal(typeof err.res.socketErrorRetries, 'number');
+          assert(err.res.socketErrorRetries >= 0 && err.res.socketErrorRetries <= 1);
         } else {
           assert.equal(err.res.socketErrorRetries, 1);
         }
📝 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.

Suggested change
if (isBun) {
// Bun's socket error code differs, retry may not trigger
assert(err.res.socketErrorRetries >= 0);
} else {
assert.equal(err.res.socketErrorRetries, 1);
}
if (isBun) {
// Bun's socket error code differs, retry may not trigger
assert.equal(typeof err.res.socketErrorRetries, 'number');
assert(err.res.socketErrorRetries >= 0 && err.res.socketErrorRetries <= 1);
} else {
assert.equal(err.res.socketErrorRetries, 1);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/options.socketErrorRetry.test.ts` around lines 45 - 50, The Bun branch
in the test uses a non-strict assertion (assert(err.res.socketErrorRetries >=
0)) which won't catch regressions; tighten it to validate retry behavior by
asserting a meaningful minimum or exact value for err.res.socketErrorRetries
when isBun is true (e.g., assert(err.res.socketErrorRetries >= 1) or
assert.equal(err.res.socketErrorRetries, expectedBunRetries)), updating the test
in options.socketErrorRetry.test.ts to use that stricter check alongside the
existing non-Bun assert.equal path so the socket retry behavior for the isBun
branch is actually verified.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
.github/workflows/bun.yml (2)

50-51: Consider pinning vitest and vite versions for reproducible builds.

Using @latest means CI results could differ between runs if vitest or vite releases a breaking change. If the intent is to always test against the latest versions, this is fine—but if reproducibility is preferred, pin to specific versions.

💡 Optional: Pin versions for reproducibility
-          pnpm install -D vitest@latest vite@latest
+          pnpm install -D vitest@^3.0.0 vite@^6.0.0
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/bun.yml around lines 50 - 51, The CI step currently
installs vitest and vite with the floating tags "pnpm install -D vitest@latest
vite@latest"; change this to pin explicit versions (e.g., vitest@<x.y.z> and
vite@<a.b.c>) or replace them with workflow variables/inputs so builds are
reproducible; update the command string "pnpm install -D vitest@latest
vite@latest" to use the chosen fixed version tokens (or env-based version
variables) and ensure the workflow passes those versions into the install step.

24-37: Consider adding pnpm caching to speed up CI runs.

The GitHub Actions versions used (actions/checkout@v6 and actions/setup-node@v6) are valid and current as of March 2026. However, the workflow is missing pnpm caching. Add cache: 'pnpm' to the actions/setup-node step to cache the pnpm store automatically.

♻️ Suggested improvement with caching
       - uses: actions/checkout@v6

       - uses: oven-sh/setup-bun@v2
         with:
           bun-version: latest

       - uses: actions/setup-node@v6
         with:
           node-version: '22'
+          cache: 'pnpm'

       - uses: pnpm/action-setup@v4

       - run: pnpm install --frozen-lockfile
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/bun.yml around lines 24 - 37, Add pnpm caching to the CI
by updating the actions/setup-node step to enable the pnpm cache; specifically
modify the setup node configuration (the "actions/setup-node@v6" step) to
include cache: 'pnpm' so the pnpm store is cached between runs and pnpm install
--frozen-lockfile runs faster. Ensure you only add the cache key to that step
and keep the existing node-version and other steps unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In @.github/workflows/bun.yml:
- Around line 50-51: The CI step currently installs vitest and vite with the
floating tags "pnpm install -D vitest@latest vite@latest"; change this to pin
explicit versions (e.g., vitest@<x.y.z> and vite@<a.b.c>) or replace them with
workflow variables/inputs so builds are reproducible; update the command string
"pnpm install -D vitest@latest vite@latest" to use the chosen fixed version
tokens (or env-based version variables) and ensure the workflow passes those
versions into the install step.
- Around line 24-37: Add pnpm caching to the CI by updating the
actions/setup-node step to enable the pnpm cache; specifically modify the setup
node configuration (the "actions/setup-node@v6" step) to include cache: 'pnpm'
so the pnpm store is cached between runs and pnpm install --frozen-lockfile runs
faster. Ensure you only add the cache key to that step and keep the existing
node-version and other steps unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2833fd58-b1b6-4fe5-a022-14d3c55e12f1

📥 Commits

Reviewing files that changed from the base of the PR and between 1a37fde and cfdb5a8.

📒 Files selected for processing (1)
  • .github/workflows/bun.yml

- Cast AbortSignal.any() to bypass TypeScript type check on older
  targets that don't include the type definition
- Fix Bun CI vitest path: use node_modules/vitest/vitest.mjs instead
  of shell wrapper script that Bun can't parse
- Fix formatting with oxfmt (import sorting, line width)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 28, 2026

Open in StackBlitz

npm i https://pkg.pr.new/node-modules/urllib@717

commit: f9d0c9a

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 28, 2026

Codecov Report

❌ Patch coverage is 55.17241% with 26 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.91%. Comparing base (020eaa3) to head (f9d0c9a).

Files with missing lines Patch % Lines
src/HttpClient.ts 63.82% 13 Missing and 4 partials ⚠️
src/FormData.ts 0.00% 7 Missing and 1 partial ⚠️
src/fetch.ts 66.66% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #717      +/-   ##
==========================================
- Coverage   92.77%   89.91%   -2.86%     
==========================================
  Files          10       10              
  Lines         747      793      +46     
  Branches      233      255      +22     
==========================================
+ Hits          693      713      +20     
- Misses         51       72      +21     
- Partials        3        8       +5     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 28, 2026 09:48
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 33 out of 33 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +27 to +51
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- uses: actions/setup-node@v6
with:
node-version: '22'

- uses: pnpm/action-setup@v4

- run: pnpm install --frozen-lockfile

- name: Patch vitest imports
run: |
for f in test/*.test.ts; do
[ -e "$f" ] || exit 0
sed --version >/dev/null 2>&1 && \
sed -i "s@'vite-plus/test'@'vitest'@g" "$f" || \
sed -i '' "s@'vite-plus/test'@'vitest'@g" "$f"
done
sed --version >/dev/null 2>&1 && \
sed -i "s@'vite-plus'@'vite'@g" vite.config.ts || \
sed -i '' "s@'vite-plus'@'vite'@g" vite.config.ts
jq 'del(.overrides) | del(.pnpm.overrides)' package.json > package.json.tmp && mv package.json.tmp package.json
pnpm install -D vitest@latest vite@latest
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI uses floating versions (bun-version: latest and pnpm install -D vitest@latest vite@latest). This makes the workflow non-deterministic and can introduce sudden failures unrelated to repo changes. Consider pinning Bun (and the patched vitest/vite versions) to known-good versions (or a stable semver range) so CI results are reproducible.

Copilot uses AI. Check for mistakes.
Comment on lines +48 to +60
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();
});
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toBuffer() attaches data/end/error listeners with on(...) but never removes them. If toBuffer() is called more than once on the same instance, listeners will accumulate and the method may resolve with duplicated data or leak memory. Consider using once(...) (and/or removing listeners on resolve/reject) to make this method safe against repeated calls.

Copilot uses AI. Check for mistakes.
let requestSignal = args.signal;
if (isBun) {
const bunTimeoutSignal = AbortSignal.timeout(headersTimeout + bodyTimeout);
requestSignal = args.signal ? (AbortSignal as any).any([bunTimeoutSignal, args.signal]) : bunTimeoutSignal;
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Bun, args.signal is allowed to be an EventEmitter (per RequestOptions.signal), but this code unconditionally feeds it into AbortSignal.any(...). If a caller passes an EventEmitter signal under Bun, this will throw synchronously (since AbortSignal.any expects AbortSignal instances), changing behavior from a request rejection to a runtime error. Consider detecting non-AbortSignal values when isBun and either (a) ignore/omit the user signal, or (b) throw a clear, early validation error explaining that Bun only supports AbortSignal for signal.

Suggested change
requestSignal = args.signal ? (AbortSignal as any).any([bunTimeoutSignal, args.signal]) : bunTimeoutSignal;
if (args.signal == null) {
// No user-provided signal: use only the timeout-based AbortSignal
requestSignal = bunTimeoutSignal;
} else if (args.signal instanceof AbortSignal) {
// User provided an AbortSignal: safely combine it with the timeout signal
requestSignal = (AbortSignal as any).any([bunTimeoutSignal, args.signal]);
} else {
// On Bun, RequestOptions.signal may be an EventEmitter or other type.
// Pass it through as-is to avoid AbortSignal.any throwing at runtime.
requestSignal = args.signal;
}

Copilot uses AI. Check for mistakes.
Comment on lines +618 to +619
if (isBun && requestOptions.body instanceof Readable) {
requestOptions.body = Readable.toWeb(requestOptions.body) as any;
Copy link

Copilot AI Mar 28, 2026

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(...).

Suggested change
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;

Copilot uses AI. Check for mistakes.
@killagu killagu marked this pull request as draft March 28, 2026 09:54
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (6)
src/HttpClient.ts (6)

555-556: ⚠️ Potential issue | 🔴 Critical

Merge the multipart boundary headers before buffering FormData.

Lines 555-556 turn args.content into raw bytes, but this branch never copies args.content.getHeaders(). Without the generated content-type boundary, caller-supplied FormData requests are malformed unless the caller manually sets the header.

💡 Suggested 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 {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/HttpClient.ts` around lines 555 - 556, The Bun branch currently converts
args.content (FormData) to a Buffer but doesn't include the multipart headers
produced by args.content.getHeaders(), so the boundary is lost; update the isBun
handling (where args.content is checked and requestOptions.body is set) to merge
the headers from (args.content as FormData).getHeaders() into
requestOptions.headers (preserving any existing headers, lower/upper-case
normalization as needed) before or alongside setting requestOptions.body so the
Content-Type with boundary is sent with the buffered body.

117-126: ⚠️ Potential issue | 🟡 Minor

Keep the canonical Node.js/... token in the User-Agent.

On Bun this replaces the stable Node.js/<version> segment with Bun/<version>, which changes the header contract for callers that parse or snapshot the existing format. Keep Node.js/${process.version.substring(1)} in the main slot and append Bun separately if you still want runtime visibility.

As per coding guidelines, **/*.ts: Set User-Agent header to node-urllib/{version} Node.js/{version} ({platform}; {arch}).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/HttpClient.ts` around lines 117 - 126, The User-Agent currently
substitutes the canonical "Node.js/{version}" token on Bun via getRuntimeInfo(),
which breaks consumers that expect "Node.js/{version}"; change the construction
so HEADER_USER_AGENT always contains "Node.js/{process.version.substring(1)}" in
the main slot and, if isBun is true, append a separate
"Bun/{process.versions.bun}" token afterwards. Update getRuntimeInfo() or the
HEADER_USER_AGENT expression to reference process.version.substring(1) for Node
and conditionally add the Bun token (using isBun and process.versions.bun)
rather than replacing Node.js.

814-819: ⚠️ Potential issue | 🟡 Minor

Report the timeout Bun actually enforced.

The Bun fallback signal is created with headersTimeout + bodyTimeout, but Lines 816 and 819 still surface headersTimeout || bodyTimeout. With split values, HttpClientRequestTimeoutError reports the wrong deadline.

💡 Suggested fix
+      const bunEnforcedTimeout = headersTimeout + bodyTimeout;
       if (err.name === 'HeadersTimeoutError') {
         err = new HttpClientRequestTimeoutError(headersTimeout, { cause: err });
       } else if (err.name === 'BodyTimeoutError') {
         err = new HttpClientRequestTimeoutError(bodyTimeout, { cause: err });
       } else if (err.name === 'InformationalError' && err.message.includes('stream timeout')) {
         err = new HttpClientRequestTimeoutError(bodyTimeout, { cause: err });
       } else if (isBun && err.name === 'TimeoutError') {
         // Bun's undici throws TimeoutError instead of HeadersTimeoutError/BodyTimeoutError
-        err = new HttpClientRequestTimeoutError(headersTimeout || bodyTimeout, { cause: err });
+        err = new HttpClientRequestTimeoutError(bunEnforcedTimeout, { 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(bunEnforcedTimeout, { cause: err });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/HttpClient.ts` around lines 814 - 819, The timeout branches for Bun
currently construct HttpClientRequestTimeoutError using headersTimeout ||
bodyTimeout which misreports the actual deadline when the fallback signal used
headersTimeout + bodyTimeout; update the two Bun branches (the err.name ===
'TimeoutError' and the TypeError with timed out message) to compute and pass the
real enforced timeout (e.g., use (headersTimeout ?? 0) + (bodyTimeout ?? 0) or a
dedicated computed variable) into the HttpClientRequestTimeoutError constructor
instead of headersTimeout || bodyTimeout so the error reflects the combined
deadline. Ensure you reference the existing HttpClientRequestTimeoutError
creation sites so only those two assignments are changed.

443-446: ⚠️ Potential issue | 🟠 Major

Narrow args.signal before calling AbortSignal.any().

Line 446 still passes the public RequestOptions.signal value straight into AbortSignal.any(). The as any cast only hides the type error; if that option is still allowed to be an EventEmitter, Bun will fail at runtime instead of composing the signal.

Run this to confirm whether RequestOptions.signal is still broader than AbortSignal. If it is, this branch needs a runtime guard before composing the signals.

#!/bin/bash
set -e
echo "RequestOptions.signal declaration:"
rg -n -C2 '\bsignal\??:' src/Request.ts
echo
echo "Bun timeout composition:"
sed -n '440,447p' src/HttpClient.ts
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/HttpClient.ts` around lines 443 - 446, The branch that composes a Bun
timeout with the incoming signal is using the public RequestOptions.signal
(args.signal) directly in AbortSignal.any — you must narrow it at runtime before
composing to avoid passing non-AbortSignal types (like EventEmitter) into
AbortSignal.any; modify the isBun branch around bunTimeoutSignal and
requestSignal so you check that args.signal is an AbortSignal (e.g., typeof
(args.signal as AbortSignal).aborted === 'boolean' or args.signal instanceof
AbortSignal) before calling AbortSignal.any, and if it is not an AbortSignal
just use bunTimeoutSignal (or otherwise wrap/convert safely) so AbortSignal.any
is only ever passed AbortSignal instances (refer to args.signal,
bunTimeoutSignal, requestSignal, and AbortSignal.any).

565-565: ⚠️ Potential issue | 🔴 Critical

Treat Bun Readable bodies as streaming requests too.

Line 565 clears isStreamingRequest for Bun, but Lines 618-620 still send the same one-shot body as a web stream. Redirects and socket-error retries remain enabled, so a replay can hit a consumed request body.

As per coding guidelines, **/*.ts: Disable retry and redirect functionality for streaming requests.

💡 Suggested fix
-          isStreamingRequest = !isBun && isReadable(args.content);
+          isStreamingRequest = isReadable(args.content);

Also applies to: 617-620

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/HttpClient.ts` at line 565, The current check clears isStreamingRequest
for Bun by using "isStreamingRequest = !isBun && isReadable(args.content)" which
causes Bun Readable bodies to be treated as non-streaming and leaves
retries/redirects enabled; change the logic to treat any Readable body as
streaming regardless of isBun (i.e., base isStreamingRequest solely on
isReadable(args.content)), and update the subsequent body-handling branch (the
code around the send-as-web-stream path referenced near the lines 617-620) to
respect isStreamingRequest so that retry and redirect behavior is disabled for
those cases; refer to the symbols isStreamingRequest, isBun, isReadable, and
args.content when making the change.

519-520: ⚠️ Potential issue | 🟠 Major

Avoid unbounded buffering for Bun multipart uploads.

This path now reads file inputs into memory, drains readable uploads into Buffer.concat(), and then materializes the full multipart body again via toBuffer(). Large uploads lose backpressure and can OOM the process. Prefer a clear Bun-only rejection for readable multipart uploads or enforce a size cap before buffering.

Also applies to: 526-548

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/HttpClient.ts` around lines 519 - 520, The current Bun branch buffers
readable file streams into memory (using isBun -> readFile and later
toBuffer/Buffer.concat) causing unbounded memory use; update the multipart
handling (symbols: isBun, readFile, createReadStream, value, toBuffer,
CombinedStream) to either (A) explicitly reject readable/stream inputs when
running under Bun with a clear error message, or (B) enforce a strict size cap
before buffering and stream to a temporary file instead of accumulating via
Buffer.concat, ensuring backpressure is preserved; modify the code paths that
convert file streams for multipart assembly to implement one of these two
strategies and surface a deterministic error for unsupported stream uploads on
Bun.
🧹 Nitpick comments (3)
test/diagnostics_channel.test.ts (1)

18-18: Don't skip the urllib:* diagnostics assertions on Bun.

This blanket suite skip also removes coverage for the library-owned urllib:request and urllib:response channels, even though src/HttpClient.ts still publishes them directly. Consider splitting the suite so only the undici-internal channel cases are Bun-gated.

As per coding guidelines, **/*.ts: Publish request/response events via diagnostics_channel with event names urllib:request and urllib:response.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/diagnostics_channel.test.ts` at line 18, The suite currently uses
describe.skipIf(isBun) which skips all tests including the library-owned
diagnostics assertions; change diagnostics_channel.test.ts to split the suite so
only undici-internal channel tests are wrapped with describe.skipIf(isBun) while
leaving the urllib channel assertions active, and ensure tests still assert that
src/HttpClient.ts publishes events on diagnostics_channel with event names
"urllib:request" and "urllib:response"; locate the tests referencing
describe.skipIf(isBun) and the assertions for "urllib:request"/"urllib:response"
and move only the undici-internal cases into the Bun-gated describe so urllib
assertions run on Bun.
test/options.fixJSONCtlChars.test.ts (1)

34-34: Prefer a Bun-specific assertion over skipping this parse-error test.

This request still goes through the same parseJSON() / JSONResponseFormatError path in src/HttpClient.ts. If Bun only changes the parser text, branch the message assertion instead of dropping Bun coverage for the error contract.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/options.fixJSONCtlChars.test.ts` at line 34, The test currently skips on
Bun; instead run it everywhere and assert the error shape but branch the
expected message based on the environment: call the same test ('should throw
error when json control characters exists'), catch the thrown error from
parseJSON()/HttpClient code, ensure it's an instance of JSONResponseFormatError,
and assert its message contains the non-Bun substring in the normal case and the
Bun-specific parser substring when isBun is true (use the isBun flag to decide
which substring to expect). This keeps the contract check
(JSONResponseFormatError) while allowing the message assertion to differ for
Bun.
test/options.content.test.ts (1)

166-166: Keep Bun coverage for content: Readable.

src/HttpClient.ts now has a Bun-specific Readable.toWeb() branch for this exact request shape. Skipping the test means Bun CI no longer exercises that compatibility path; if the wire-level headers differ, branch those assertions instead of disabling the test.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/options.content.test.ts` at line 166, The test "should POST content =
readable" has been skipped for Bun (it.skipIf(isBun)), which prevents exercising
the Bun-specific Readable.toWeb path in src/HttpClient.ts; remove the skip so
Bun runs the test, and instead update the test's assertions to branch or accept
both header shapes (e.g., detect isBun and assert the Bun-specific wire-level
headers or use a flexible matcher) so the test passes across runtimes while
still exercising the Readable.toWeb compatibility branch.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/HttpClient.ts`:
- Around line 555-556: The Bun branch currently converts args.content (FormData)
to a Buffer but doesn't include the multipart headers produced by
args.content.getHeaders(), so the boundary is lost; update the isBun handling
(where args.content is checked and requestOptions.body is set) to merge the
headers from (args.content as FormData).getHeaders() into requestOptions.headers
(preserving any existing headers, lower/upper-case normalization as needed)
before or alongside setting requestOptions.body so the Content-Type with
boundary is sent with the buffered body.
- Around line 117-126: The User-Agent currently substitutes the canonical
"Node.js/{version}" token on Bun via getRuntimeInfo(), which breaks consumers
that expect "Node.js/{version}"; change the construction so HEADER_USER_AGENT
always contains "Node.js/{process.version.substring(1)}" in the main slot and,
if isBun is true, append a separate "Bun/{process.versions.bun}" token
afterwards. Update getRuntimeInfo() or the HEADER_USER_AGENT expression to
reference process.version.substring(1) for Node and conditionally add the Bun
token (using isBun and process.versions.bun) rather than replacing Node.js.
- Around line 814-819: The timeout branches for Bun currently construct
HttpClientRequestTimeoutError using headersTimeout || bodyTimeout which
misreports the actual deadline when the fallback signal used headersTimeout +
bodyTimeout; update the two Bun branches (the err.name === 'TimeoutError' and
the TypeError with timed out message) to compute and pass the real enforced
timeout (e.g., use (headersTimeout ?? 0) + (bodyTimeout ?? 0) or a dedicated
computed variable) into the HttpClientRequestTimeoutError constructor instead of
headersTimeout || bodyTimeout so the error reflects the combined deadline.
Ensure you reference the existing HttpClientRequestTimeoutError creation sites
so only those two assignments are changed.
- Around line 443-446: The branch that composes a Bun timeout with the incoming
signal is using the public RequestOptions.signal (args.signal) directly in
AbortSignal.any — you must narrow it at runtime before composing to avoid
passing non-AbortSignal types (like EventEmitter) into AbortSignal.any; modify
the isBun branch around bunTimeoutSignal and requestSignal so you check that
args.signal is an AbortSignal (e.g., typeof (args.signal as AbortSignal).aborted
=== 'boolean' or args.signal instanceof AbortSignal) before calling
AbortSignal.any, and if it is not an AbortSignal just use bunTimeoutSignal (or
otherwise wrap/convert safely) so AbortSignal.any is only ever passed
AbortSignal instances (refer to args.signal, bunTimeoutSignal, requestSignal,
and AbortSignal.any).
- Line 565: The current check clears isStreamingRequest for Bun by using
"isStreamingRequest = !isBun && isReadable(args.content)" which causes Bun
Readable bodies to be treated as non-streaming and leaves retries/redirects
enabled; change the logic to treat any Readable body as streaming regardless of
isBun (i.e., base isStreamingRequest solely on isReadable(args.content)), and
update the subsequent body-handling branch (the code around the
send-as-web-stream path referenced near the lines 617-620) to respect
isStreamingRequest so that retry and redirect behavior is disabled for those
cases; refer to the symbols isStreamingRequest, isBun, isReadable, and
args.content when making the change.
- Around line 519-520: The current Bun branch buffers readable file streams into
memory (using isBun -> readFile and later toBuffer/Buffer.concat) causing
unbounded memory use; update the multipart handling (symbols: isBun, readFile,
createReadStream, value, toBuffer, CombinedStream) to either (A) explicitly
reject readable/stream inputs when running under Bun with a clear error message,
or (B) enforce a strict size cap before buffering and stream to a temporary file
instead of accumulating via Buffer.concat, ensuring backpressure is preserved;
modify the code paths that convert file streams for multipart assembly to
implement one of these two strategies and surface a deterministic error for
unsupported stream uploads on Bun.

---

Nitpick comments:
In `@test/diagnostics_channel.test.ts`:
- Line 18: The suite currently uses describe.skipIf(isBun) which skips all tests
including the library-owned diagnostics assertions; change
diagnostics_channel.test.ts to split the suite so only undici-internal channel
tests are wrapped with describe.skipIf(isBun) while leaving the urllib channel
assertions active, and ensure tests still assert that src/HttpClient.ts
publishes events on diagnostics_channel with event names "urllib:request" and
"urllib:response"; locate the tests referencing describe.skipIf(isBun) and the
assertions for "urllib:request"/"urllib:response" and move only the
undici-internal cases into the Bun-gated describe so urllib assertions run on
Bun.

In `@test/options.content.test.ts`:
- Line 166: The test "should POST content = readable" has been skipped for Bun
(it.skipIf(isBun)), which prevents exercising the Bun-specific Readable.toWeb
path in src/HttpClient.ts; remove the skip so Bun runs the test, and instead
update the test's assertions to branch or accept both header shapes (e.g.,
detect isBun and assert the Bun-specific wire-level headers or use a flexible
matcher) so the test passes across runtimes while still exercising the
Readable.toWeb compatibility branch.

In `@test/options.fixJSONCtlChars.test.ts`:
- Line 34: The test currently skips on Bun; instead run it everywhere and assert
the error shape but branch the expected message based on the environment: call
the same test ('should throw error when json control characters exists'), catch
the thrown error from parseJSON()/HttpClient code, ensure it's an instance of
JSONResponseFormatError, and assert its message contains the non-Bun substring
in the normal case and the Bun-specific parser substring when isBun is true (use
the isBun flag to decide which substring to expect). This keeps the contract
check (JSONResponseFormatError) while allowing the message assertion to differ
for Bun.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7e225abe-8968-4a3e-92fa-e34e744afda9

📥 Commits

Reviewing files that changed from the base of the PR and between cfdb5a8 and f9d0c9a.

📒 Files selected for processing (31)
  • .github/workflows/bun.yml
  • src/HttpClient.ts
  • test/HttpClient.connect.rejectUnauthorized.test.ts
  • test/HttpClient.events.test.ts
  • test/HttpClient.test.ts
  • test/diagnostics_channel.test.ts
  • test/fetch.test.ts
  • test/head-request-should-keepalive.test.ts
  • test/index.test.ts
  • test/keep-alive-header.test.ts
  • test/non-ascii-request-header.test.ts
  • test/options.compressed.test.ts
  • test/options.content.test.ts
  • test/options.data.test.ts
  • test/options.dataType.test.ts
  • test/options.fixJSONCtlChars.test.ts
  • test/options.followRedirect.test.ts
  • test/options.gzip.test.ts
  • test/options.headers.test.ts
  • test/options.opaque.test.ts
  • test/options.reset.test.ts
  • test/options.signal.test.ts
  • test/options.socketErrorRetry.test.ts
  • test/options.socketPath.test.ts
  • test/options.stream.test.ts
  • test/options.streaming.test.ts
  • test/options.timeout.test.ts
  • test/options.timing.test.ts
  • test/options.writeStream.test.ts
  • test/urllib.options.rejectUnauthorized-false.test.ts
  • test/user-agent.test.ts
✅ Files skipped from review due to trivial changes (10)
  • test/HttpClient.connect.rejectUnauthorized.test.ts
  • test/options.reset.test.ts
  • test/options.followRedirect.test.ts
  • test/options.headers.test.ts
  • test/fetch.test.ts
  • test/keep-alive-header.test.ts
  • test/index.test.ts
  • test/HttpClient.test.ts
  • test/options.compressed.test.ts
  • test/options.gzip.test.ts
🚧 Files skipped from review as they are similar to previous changes (11)
  • test/options.opaque.test.ts
  • test/options.dataType.test.ts
  • test/head-request-should-keepalive.test.ts
  • test/options.timing.test.ts
  • test/options.signal.test.ts
  • test/urllib.options.rejectUnauthorized-false.test.ts
  • test/user-agent.test.ts
  • test/options.data.test.ts
  • .github/workflows/bun.yml
  • test/options.socketErrorRetry.test.ts
  • test/options.streaming.test.ts

@fengmk2
Copy link
Copy Markdown
Member

fengmk2 commented Mar 28, 2026

趁这个改动直接将 nodejs 最低版本也升级到 v22? 可以发一个 major 变更

@fengmk2
Copy link
Copy Markdown
Member

fengmk2 commented Mar 28, 2026

我先改一下发布流程,改成手动发布

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants