Skip to content

[WIP] feat: add Bun runtime compatibility#5843

Open
killagu wants to merge 1 commit intonextfrom
feat/bun
Open

[WIP] feat: add Bun runtime compatibility#5843
killagu wants to merge 1 commit intonextfrom
feat/bun

Conversation

@killagu
Copy link
Copy Markdown
Contributor

@killagu killagu commented Mar 28, 2026

Summary

Add Bun runtime support for the Egg.js monorepo. Current status: 94.9% tests passing (2771/2918 non-skip) under Bun 1.3.5.

Runtime fixes

  • callSite.getScriptHash() guard — Bun doesn't have this V8 API; blocked all tegg-dependent tests
  • 204/304 explicit removeHeader — Bun HTTP server doesn't strip content headers on empty status; koa _respond now explicitly removes them
  • httpclient AbortSignal.timeout fallback — Bun's undici doesn't honor headersTimeout/bodyTimeout; egg HttpClient adds AbortSignal.timeout as fallback, supports timeout: number | number[]
  • pnpm virtual store framework resolution — Bun module resolution skips pnpm symlinks; added .pnpm/node_modules as search path
  • node:test mock compat shim — Bun's node:test mock is incomplete; created mock_compat.ts with method/fn/reset support

CI

  • New test-bun job: ubuntu + macos, Bun latest, 3 shards, Redis + MySQL

Known issues (blocked on urllib)

  • ~50 mock_httpclient tests: Bun intercepts import 'undici' with a built-in stub that has an empty MockAgent. Need urllib to use fetch() instead of undiciRequest() on Bun so that HTTP mocking can work
  • body_parser gzip hang: Bun zlib stream decompression bug — potential DoS vector, needs upstream fix
  • Bun HTTP behavior diffs: rawHeaders lowercase, TRACE empty body, keep-alive header missing, expired cert rejection

Test results (local, Bun 1.3.5)

Test Files  37 failed | 449 passed | 26 skipped (512)
     Tests  147 failed | 2771 passed | 396 skipped (3330)

Most failures are mock_httpclient (~50) and view-nunjucks (~20), both blocked on the urllib undici issue.

Test plan

  • CI test-bun job passes on ubuntu and macos
  • urllib adapted for Bun (separate PR)
  • Re-enable mock_httpclient tests after urllib update
  • Report Bun upstream bugs (zlib hang, content-length on 204, MockAgent stub)

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • Chores

    • Added Bun runtime support to CI pipeline with comprehensive test coverage across multiple platforms
    • Introduced test mocking compatibility layer to standardize testing utilities across Bun and Node.js environments
    • Updated test suites for runtime compatibility with conditional test execution for environment-specific behaviors
  • Bug Fixes

    • Fixed HTTP client timeout handling for Bun runtime
    • Improved response header handling for empty status codes across different runtime environments
    • Enhanced module resolution paths for better package discovery

Runtime fixes:
- guard callSite.getScriptHash() for Bun (tegg/core/common-util)
- explicit removeHeader on 204/304/205 responses (packages/koa)
- AbortSignal.timeout fallback for httpclient timeout (packages/egg)
- pnpm virtual store path in framework resolution (packages/utils)
- node:test mock API compat shim for Bun (tegg/core/test-util)

CI:
- add test-bun job to GitHub Actions (ubuntu + macos, Bun latest)

Known issues:
- urllib needs Bun adapter (fetch instead of undiciRequest) for MockAgent
- Bun zlib stream decompression bug (body_parser gzip hang)
- Bun HTTP behavior differences documented in test skips

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

coderabbitai bot commented Mar 28, 2026

📝 Walkthrough

Walkthrough

This PR introduces Bun runtime support across the Egg.js framework by adding a CI job to test with Bun, creating a test mocking compatibility shim, conditionally skipping incompatible tests in Bun environments, and implementing Bun-specific timeout handling for HTTP clients. Core framework features like empty response header management and module resolution paths are also adjusted for cross-runtime compatibility.

Changes

Cohort / File(s) Summary
CI and Configuration
.github/workflows/ci.yml, .gitignore
Added test-bun CI job to run test shards with Redis/MySQL services, and added Bun cache directory to ignore rules.
Mock Compatibility Shim
tegg/core/test-util/src/mock_compat.ts, tegg/core/test-util/package.json, tegg/core/test-util/src/EggTestContext.ts
Created new mock_compat module that polyfills Node's node:test mock API for Bun, with conditional dynamic import for non-Bun runtimes. Exported via new subpath in package.json and adopted in test context.
Test Mocking Import Migration
tegg/core/aop-runtime/test/aop-runtime.test.ts, tegg/core/dal-runtime/test/DataSource.test.ts, tegg/core/eventbus-runtime/test/EventBus.test.ts, tegg/core/runtime/test/EggObject.test.ts, tegg/core/runtime/test/EggObjectUtil.test.ts, tegg/core/runtime/test/LoadUnitInstance.test.ts, tegg/core/runtime/test/QualifierLoadUnitInstance.test.ts
Updated import source for mock from node:test to @eggjs/module-test-util/mock_compat across multiple test files.
Bun-incompatible Test Skips
packages/cluster/test/https.test.ts, packages/egg/test/agent.test.ts, packages/egg/test/app/extend/response.test.ts, packages/egg/test/app/middleware/body_parser.test.ts, packages/egg/test/app/middleware/meta.test.ts, packages/egg/test/cluster1/app_worker.test.ts, packages/egg/test/lib/core/dnscache_httpclient.test.ts, packages/egg/test/lib/plugins/multipart.test.ts, packages/koa/test/application/response.test.ts, packages/koa/test/response/status.test.ts, packages/supertest/test/supertest.test.ts
Added Bun runtime detection and conditionally skipped tests or entire test suites that are incompatible with Bun via describe.skipIf(isBun) or it.skipIf(isBun), with adjusted assertions for platform-specific error messages.
HTTP Client Timeout Handling
packages/egg/src/lib/core/httpclient.ts
Introduced Bun-specific timeout fallback: when running on Bun without options.signal, compute timeout from config and use AbortSignal.timeout(timeoutMs), with error handling for timeout scenarios.
Test Assertion Updates
packages/cluster/test/options.test.ts, packages/core/test/loader/file_loader.test.ts, packages/core/test/loader/mixin/load_extend.test.ts, packages/utils/test/import.test.ts
Updated error message regex patterns to accept additional error message variants (e.g., matching both module and package in resolution errors).
Framework and Resolution Updates
packages/koa/src/application.ts, packages/utils/src/framework.ts, tegg/core/common-util/src/StackUtil.ts, packages/egg/test/lib/core/messenger/local.test.ts, tegg/core/dal-runtime/package.json
Fixed empty response body header handling, expanded module resolution to include pnpm virtual store, guarded getScriptHash access, converted test to async/await pattern, and added test-util dependency.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • eggjs/egg#5722: Adds conditional test skips in overlapping files with version/engine checks (e.g., packages/cluster/test/options.test.ts), indicating related refactoring of test compatibility handling.
  • eggjs/egg#5530: Modifies the same packages/egg/src/lib/core/httpclient.ts file and HttpClient.request method, suggesting related HTTP client improvements.
  • eggjs/egg#5454: Updates test skipping behavior in packages/egg/test/cluster1/app_worker.test.ts, directly related to platform-specific test conditions.

Suggested reviewers

  • fengmk2
  • gxkl

Poem

🐰 Hop hop, Bun now runs so fast,
Tests that skip when Bun's here vast,
Mock shims polyfill the way,
Timeouts caught both night and day,
Cross-runtime magic here to stay! ✨

🚥 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 title '[WIP] feat: add Bun runtime compatibility' directly and clearly summarizes the primary change: adding support for the Bun runtime across the Egg.js monorepo.

✏️ 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

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.

cache: 'pnpm'

- name: Set up Bun
uses: oven-sh/setup-bun@v2

Check warning

Code scanning / CodeQL

Unpinned tag for a non-immutable Action in workflow Medium

Unpinned 3rd party Action 'CI' step
Uses Step
uses 'oven-sh/setup-bun' with ref 'v2', not a pinned commit hash
@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying egg with  Cloudflare Pages  Cloudflare Pages

Latest commit: 756d81e
Status: ✅  Deploy successful!
Preview URL: https://200a7daa.egg-cci.pages.dev
Branch Preview URL: https://feat-bun.egg-cci.pages.dev

View logs

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 28, 2026

Codecov Report

❌ Patch coverage is 30.00000% with 21 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.62%. Comparing base (3ec8105) to head (756d81e).
⚠️ Report is 6 commits behind head on next.

Files with missing lines Patch % Lines
tegg/core/test-util/src/mock_compat.ts 15.38% 11 Missing ⚠️
packages/egg/src/lib/core/httpclient.ts 9.09% 7 Missing and 3 partials ⚠️

❗ There is a different number of reports uploaded between BASE (3ec8105) and HEAD (756d81e). Click for more details.

HEAD has 6 uploads less than BASE
Flag BASE (3ec8105) HEAD (756d81e)
23 17
Additional details and impacted files
@@            Coverage Diff             @@
##             next    #5843      +/-   ##
==========================================
- Coverage   85.62%   79.62%   -6.01%     
==========================================
  Files         665      634      -31     
  Lines       13004    12480     -524     
  Branches     1495     1466      -29     
==========================================
- Hits        11135     9937    -1198     
- Misses       1745     2367     +622     
- Partials      124      176      +52     

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

Copy link
Copy Markdown
Contributor

@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 introduces compatibility for the Bun runtime across several packages. Key changes include Bun-specific workarounds for HTTP client timeouts, explicit header management in Koa for empty responses, and adjusted module resolution for pnpm virtual stores. Additionally, a mock_compat utility was added to polyfill node:test for Bun. Feedback was provided regarding a logic flaw in the mock_compat reset function, which should restore mocks in reverse order to correctly handle nested mocks.

Comment on lines +30 to +35
reset(): void {
for (const { obj, key, original } of originals) {
obj[key] = original;
}
originals.length = 0;
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The reset function has a flaw in its logic for restoring mocks. When the same method is mocked multiple times, the current for...of loop restores them in the order they were applied. This can result in the method being left in an intermediate mocked state, rather than its original state.

To ensure the original implementation is always restored, the mocks should be reverted in the reverse order of their application. Using Array.prototype.pop() in a while loop is a good way to achieve this LIFO (Last-In, First-Out) behavior.

    reset(): void {
      // Restore in reverse order to handle nested mocks correctly.
      while (originals.length) {
        const { obj, key, original } = originals.pop()!;
        obj[key] = original;
      }
    },

@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying egg-v3 with  Cloudflare Pages  Cloudflare Pages

Latest commit: 756d81e
Status: ✅  Deploy successful!
Preview URL: https://717feb35.egg-v3.pages.dev
Branch Preview URL: https://feat-bun.egg-v3.pages.dev

View logs

Copy link
Copy Markdown
Contributor

@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: 6

🧹 Nitpick comments (3)
packages/egg/test/app/middleware/body_parser.test.ts (1)

89-93: Use a specific Bun issue link for this skip condition.

The generic tracker URL makes it hard to know when this case should be re-enabled; a concrete issue ID keeps the skip actionable.

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

In `@packages/egg/test/app/middleware/body_parser.test.ts` around lines 89 - 93,
Update the skip comment that references a generic Bun tracker by replacing the
generic URL with the specific Bun issue/PR URL and issue number so the skip is
actionable; locate the test using isBun and the it.skipIf call for "should 400
when GET with invalid body" and change the comment block above it to cite the
concrete Bun issue ID/URL (e.g., https://github.com/oven-sh/bun/issues/NNN) and
optionally add the issue title and date.
tegg/core/test-util/src/mock_compat.ts (1)

30-35: reset() doesn't handle property descriptors for potential getter/setter mocks.

If mock.getter is added, the current reset() implementation using simple assignment (obj[key] = original) won't correctly restore property descriptors. Consider storing and restoring full descriptors:

🔧 Proposed fix for robust reset
+    type OriginalEntry = 
+      | { type: 'value'; obj: any; key: string | symbol; original: any }
+      | { type: 'descriptor'; obj: any; key: string | symbol; original: PropertyDescriptor | undefined };
+
-    const originals: Array<{ obj: any; key: string | symbol; original: any }> = [];
+    const originals: OriginalEntry[] = [];
 
     // In method():
-    originals.push({ obj, key, original: obj[key] });
+    originals.push({ type: 'value', obj, key, original: obj[key] });
 
     // In reset():
     reset(): void {
-      for (const { obj, key, original } of originals) {
-        obj[key] = original;
+      for (const entry of originals) {
+        if (entry.type === 'value') {
+          entry.obj[entry.key] = entry.original;
+        } else if (entry.original) {
+          Object.defineProperty(entry.obj, entry.key, entry.original);
+        } else {
+          delete entry.obj[entry.key];
+        }
       }
       originals.length = 0;
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tegg/core/test-util/src/mock_compat.ts` around lines 30 - 35, The reset()
routine currently restores mocks by simple assignment (obj[key] = original)
which breaks getter/setter descriptors; change reset() to restore the full
property descriptor using Object.defineProperty(obj, key, originalDescriptor)
and ensure the stored "originals" entries record the full descriptor (from
Object.getOwnPropertyDescriptor or a created descriptor) rather than a value;
update any mock-recording code that pushes into originals to save the descriptor
under a field like originalDescriptor so reset() can call Object.defineProperty
with that descriptor and then clear originals.
.github/workflows/ci.yml (1)

239-240: Consider removing --bail 1 to get full test visibility in CI.

--bail 1 stops the test run after the first failure. While this saves CI time, it reduces visibility into the full scope of failures, making it harder to assess Bun compatibility progress. Given the PR objective mentions ~147 failing tests, seeing all failures per shard would be more informative.

Alternatively, keep --bail 1 but add a comment explaining the rationale (e.g., to save CI minutes during active development).

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

In @.github/workflows/ci.yml around lines 239 - 240, The CI step named "Run
tests with Bun" currently passes the `--bail 1` flag to Vitest which stops on
the first failure; either remove `--bail 1` from the command (`bun
./node_modules/vitest/vitest.mjs run ...`) so the full shard test output is
shown, or if you must keep it, add an inline comment in the same workflow step
explaining the rationale (e.g., to save CI minutes during active development) so
reviewers understand why `--bail 1` is present.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.github/workflows/ci.yml:
- Around line 231-234: The workflow step named "Set up Bun" currently uses the
floating tag oven-sh/setup-bun@v2; replace that with a specific commit SHA to
pin the action (e.g., oven-sh/setup-bun@<commit-sha>) so the action cannot
change unexpectedly. Update the uses value for the "Set up Bun" step to
reference the exact commit hash from the oven-sh/setup-bun repository (grab the
latest stable commit SHA from the action's repo/releases) and commit that
change.

In `@packages/egg/src/lib/core/httpclient.ts`:
- Around line 60-62: The current computation of timeoutMs using
Math.min(...rawTimeout.filter(...)) can produce Infinity when the filtered array
is empty; update the logic around the rawTimeout -> timeoutMs computation so you
first collect the positive numeric values (e.g., const positives =
Array.isArray(rawTimeout) ? rawTimeout.filter((t): t is number => typeof t ===
'number' && t > 0) : null), then if positives is empty set timeoutMs to
undefined (or a safe default) instead of calling Math.min, otherwise set
timeoutMs = Math.min(...positives); ensure the rest of the code that uses
timeoutMs (e.g., AbortSignal.timeout(timeoutMs)) guards against
undefined/infinite values.

In `@packages/koa/src/application.ts`:
- Around line 271-275: The code unconditionally calls
res.removeHeader('Content-Type'/'Content-Length'/'Transfer-Encoding'), which can
throw ERR_HTTP_HEADERS_SENT if headers were already sent; update the
raw-response header cleanup to first check if (!res.headersSent) before calling
res.removeHeader for each header (mirror the existing guard used in the HEAD
method branch) so header removal only occurs when safe.

In `@tegg/core/runtime/test/EggObject.test.ts`:
- Line 4: The test fails on Bun because mock.getter is not implemented in the
Bun polyfill; update the mock_compat.ts polyfill by adding a mock.getter
signature to the MockContext interface and an implementation that defines a
getter on the target object (e.g., using Object.defineProperty to set the
property getter or wiring the underlying test framework’s getter-mocking
behavior) so calls like mock.getter(bar, 'foo', () => foo) work, or
alternatively conditionally skip the EggObject.test.ts block on Bun; ensure the
new symbol is named mock.getter and behaves consistently with the existing
mock.method/mock.fn semantics.

In `@tegg/core/test-util/package.json`:
- Line 32: The package export for "./mock_compat" is declared but the tsdown
build config only defines the "index" entry, so mock_compat.ts will not be
built; update tegg/core/test-util/tsdown.config.ts to add an entry for
"mock_compat" (pointing to "src/mock_compat.ts") alongside the existing "index"
entry so the build emits dist/mock_compat.js and matches the
publishConfig.exports; ensure the entry key name matches the export path
("mock_compat") and that tsdown's entries object includes both "index":
"src/index.ts" and "mock_compat": "src/mock_compat.ts".

In `@tegg/core/test-util/src/mock_compat.ts`:
- Around line 5-9: The MockContext interface and Bun polyfill lack mock.getter
and mock.setter causing runtime failures; add two methods to the MockContext
interface named getter(obj: object, key: PropertyKey, impl: () => any): void and
setter(obj: object, key: PropertyKey, impl: (v: any) => any): void, implement
corresponding functions in the Bun polyfill alongside method, fn, and reset (use
Object.getOwnPropertyDescriptor/Object.defineProperty to replace property
accessors and store original descriptors), and update reset() to restore
original property descriptors rather than only replacing values so
getters/setters are properly cleaned up (refer to MockContext, method, fn, and
reset to find the existing implementation to extend).

---

Nitpick comments:
In @.github/workflows/ci.yml:
- Around line 239-240: The CI step named "Run tests with Bun" currently passes
the `--bail 1` flag to Vitest which stops on the first failure; either remove
`--bail 1` from the command (`bun ./node_modules/vitest/vitest.mjs run ...`) so
the full shard test output is shown, or if you must keep it, add an inline
comment in the same workflow step explaining the rationale (e.g., to save CI
minutes during active development) so reviewers understand why `--bail 1` is
present.

In `@packages/egg/test/app/middleware/body_parser.test.ts`:
- Around line 89-93: Update the skip comment that references a generic Bun
tracker by replacing the generic URL with the specific Bun issue/PR URL and
issue number so the skip is actionable; locate the test using isBun and the
it.skipIf call for "should 400 when GET with invalid body" and change the
comment block above it to cite the concrete Bun issue ID/URL (e.g.,
https://github.com/oven-sh/bun/issues/NNN) and optionally add the issue title
and date.

In `@tegg/core/test-util/src/mock_compat.ts`:
- Around line 30-35: The reset() routine currently restores mocks by simple
assignment (obj[key] = original) which breaks getter/setter descriptors; change
reset() to restore the full property descriptor using Object.defineProperty(obj,
key, originalDescriptor) and ensure the stored "originals" entries record the
full descriptor (from Object.getOwnPropertyDescriptor or a created descriptor)
rather than a value; update any mock-recording code that pushes into originals
to save the descriptor under a field like originalDescriptor so reset() can call
Object.defineProperty with that descriptor and then clear originals.
🪄 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: 88d26adf-15e4-48e6-b412-0b7e73ed7e40

📥 Commits

Reviewing files that changed from the base of the PR and between 53bd879 and 756d81e.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (33)
  • .github/workflows/ci.yml
  • .gitignore
  • packages/cluster/test/https.test.ts
  • packages/cluster/test/options.test.ts
  • packages/core/test/loader/file_loader.test.ts
  • packages/core/test/loader/mixin/load_extend.test.ts
  • packages/egg/src/lib/core/httpclient.ts
  • packages/egg/test/agent.test.ts
  • packages/egg/test/app/extend/response.test.ts
  • packages/egg/test/app/middleware/body_parser.test.ts
  • packages/egg/test/app/middleware/meta.test.ts
  • packages/egg/test/cluster1/app_worker.test.ts
  • packages/egg/test/lib/core/dnscache_httpclient.test.ts
  • packages/egg/test/lib/core/messenger/local.test.ts
  • packages/egg/test/lib/plugins/multipart.test.ts
  • packages/koa/src/application.ts
  • packages/koa/test/application/response.test.ts
  • packages/koa/test/response/status.test.ts
  • packages/supertest/test/supertest.test.ts
  • packages/utils/src/framework.ts
  • packages/utils/test/import.test.ts
  • tegg/core/aop-runtime/test/aop-runtime.test.ts
  • tegg/core/common-util/src/StackUtil.ts
  • tegg/core/dal-runtime/package.json
  • tegg/core/dal-runtime/test/DataSource.test.ts
  • tegg/core/eventbus-runtime/test/EventBus.test.ts
  • tegg/core/runtime/test/EggObject.test.ts
  • tegg/core/runtime/test/EggObjectUtil.test.ts
  • tegg/core/runtime/test/LoadUnitInstance.test.ts
  • tegg/core/runtime/test/QualifierLoadUnitInstance.test.ts
  • tegg/core/test-util/package.json
  • tegg/core/test-util/src/EggTestContext.ts
  • tegg/core/test-util/src/mock_compat.ts

Comment on lines +231 to +234
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ matrix.bun }}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

What is the latest commit hash for oven-sh/setup-bun v2?

💡 Result:

The latest commit hash for oven-sh/setup-bun v2 is 0c5077e. This is the commit associated with the latest v2 release, v2.2.0, released on 2026-03-14. GitHub Actions typically reference releases by tag (e.g., oven-sh/setup-bun@v2), which points to this commit. The repository's main branch last push was on 2026-03-14T09:38:41Z, matching the release time, suggesting no later commits on main.

Citations:


Pin the oven-sh/setup-bun action to a commit hash for supply chain security.

Using @v2 instead of a pinned commit hash allows the action to be updated without your knowledge, which is a supply chain risk. Other actions in this workflow are properly pinned.

🔧 Proposed fix
       - name: Set up Bun
-        uses: oven-sh/setup-bun@v2
+        uses: oven-sh/setup-bun@0c5077e # v2.2.0
         with:
           bun-version: ${{ matrix.bun }}
📝 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
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ matrix.bun }}
- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e # v2.2.0
with:
bun-version: ${{ matrix.bun }}
🧰 Tools
🪛 GitHub Check: CodeQL

[warning] 232-232: Unpinned tag for a non-immutable Action in workflow
Unpinned 3rd party Action 'CI' step Uses Step uses 'oven-sh/setup-bun' with ref 'v2', not a pinned commit hash

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

In @.github/workflows/ci.yml around lines 231 - 234, The workflow step named
"Set up Bun" currently uses the floating tag oven-sh/setup-bun@v2; replace that
with a specific commit SHA to pin the action (e.g.,
oven-sh/setup-bun@<commit-sha>) so the action cannot change unexpectedly. Update
the uses value for the "Set up Bun" step to reference the exact commit hash from
the oven-sh/setup-bun repository (grab the latest stable commit SHA from the
action's repo/releases) and commit that change.

Comment on lines +60 to +62
const timeoutMs = Array.isArray(rawTimeout)
? Math.min(...rawTimeout.filter((t): t is number => typeof t === 'number' && t > 0))
: rawTimeout;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Edge case: Math.min with empty spread returns Infinity.

If rawTimeout is an array containing only non-positive values (e.g., [0, -1]), the filter returns an empty array, and Math.min(...[]) evaluates to Infinity. Since Infinity > 0 is true, this would create AbortSignal.timeout(Infinity).

Consider adding a guard for this edge case:

Proposed fix
       const timeoutMs = Array.isArray(rawTimeout)
-        ? Math.min(...rawTimeout.filter((t): t is number => typeof t === 'number' && t > 0))
+        ? rawTimeout.filter((t): t is number => typeof t === 'number' && t > 0).reduce((a, b) => Math.min(a, b), 0)
         : rawTimeout;
+      if (typeof timeoutMs === 'number' && timeoutMs > 0 && Number.isFinite(timeoutMs)) {
-      if (typeof timeoutMs === 'number' && timeoutMs > 0) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/egg/src/lib/core/httpclient.ts` around lines 60 - 62, The current
computation of timeoutMs using Math.min(...rawTimeout.filter(...)) can produce
Infinity when the filtered array is empty; update the logic around the
rawTimeout -> timeoutMs computation so you first collect the positive numeric
values (e.g., const positives = Array.isArray(rawTimeout) ?
rawTimeout.filter((t): t is number => typeof t === 'number' && t > 0) : null),
then if positives is empty set timeoutMs to undefined (or a safe default)
instead of calling Math.min, otherwise set timeoutMs = Math.min(...positives);
ensure the rest of the code that uses timeoutMs (e.g.,
AbortSignal.timeout(timeoutMs)) guards against undefined/infinite values.

Comment on lines +271 to +275
// explicitly remove content headers from the raw response
// to ensure they are not sent (some runtimes like Bun don't strip them automatically)
res.removeHeader('Content-Type');
res.removeHeader('Content-Length');
res.removeHeader('Transfer-Encoding');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Node.js HTTP ServerResponse, what is the behavior of response.removeHeader(name) if headers have already been sent? Does it throw ERR_HTTP_HEADERS_SENT?

💡 Result:

Yes, in Node.js HTTP ServerResponse, calling response.removeHeader(name) after headers have already been sent throws Error [ERR_HTTP_HEADERS_SENT]: Cannot remove headers after they are sent to the client.

Citations:


🏁 Script executed:

cd packages/koa && wc -l src/application.ts

Repository: eggjs/egg

Length of output: 75


🏁 Script executed:

cd packages/koa && sed -n '250,300p' src/application.ts

Repository: eggjs/egg

Length of output: 1460


🏁 Script executed:

cd packages/koa && sed -n '200,250p' src/application.ts

Repository: eggjs/egg

Length of output: 1633


Guard raw header removal with !res.headersSent check.

At lines 273-275, calling res.removeHeader() unconditionally will throw ERR_HTTP_HEADERS_SENT if headers were already sent during middleware execution, breaking response finalization in the empty-status path. The HEAD method branch (line 284) already demonstrates this pattern with a guard check.

Suggested fix
-      res.removeHeader('Content-Type');
-      res.removeHeader('Content-Length');
-      res.removeHeader('Transfer-Encoding');
+      if (!res.headersSent) {
+        res.removeHeader('Content-Type');
+        res.removeHeader('Content-Length');
+        res.removeHeader('Transfer-Encoding');
+      }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koa/src/application.ts` around lines 271 - 275, The code
unconditionally calls
res.removeHeader('Content-Type'/'Content-Length'/'Transfer-Encoding'), which can
throw ERR_HTTP_HEADERS_SENT if headers were already sent; update the
raw-response header cleanup to first check if (!res.headersSent) before calling
res.removeHeader for each header (mirror the existing guard used in the HEAD
method branch) so header removal only occurs when safe.

import { mock } from 'node:test';

import { EggPrototypeFactory } from '@eggjs/metadata';
import { mock } from '@eggjs/module-test-util/mock_compat';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for all mock.getter usages across tegg tests to assess scope of missing implementation
rg -n 'mock\.getter' tegg/

Repository: eggjs/egg

Length of output: 139


🏁 Script executed:

#!/bin/bash
# Locate and inspect the mock_compat.ts file
fd -t f 'mock_compat' tegg/

Repository: eggjs/egg

Length of output: 91


🏁 Script executed:

#!/bin/bash
# Read the mock_compat.ts file to verify implementation
cat -n tegg/core/test-util/src/mock_compat.ts

Repository: eggjs/egg

Length of output: 1739


🏁 Script executed:

#!/bin/bash
# Read the EggObject.test.ts file around line 152 to see the context
sed -n '145,160p' tegg/core/runtime/test/EggObject.test.ts

Repository: eggjs/egg

Length of output: 601


mock.getter is missing from the Bun polyfill — this test will fail on Bun.

The mock_compat.ts polyfill only implements method, fn, and reset. However, line 152 calls mock.getter(bar, 'foo', () => foo), which will throw TypeError: mock.getter is not a function on Bun.

Add mock.getter to the MockContext interface and polyfill implementation in tegg/core/test-util/src/mock_compat.ts, or skip this test block on Bun.

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

In `@tegg/core/runtime/test/EggObject.test.ts` at line 4, The test fails on Bun
because mock.getter is not implemented in the Bun polyfill; update the
mock_compat.ts polyfill by adding a mock.getter signature to the MockContext
interface and an implementation that defines a getter on the target object
(e.g., using Object.defineProperty to set the property getter or wiring the
underlying test framework’s getter-mocking behavior) so calls like
mock.getter(bar, 'foo', () => foo) work, or alternatively conditionally skip the
EggObject.test.ts block on Bun; ensure the new symbol is named mock.getter and
behaves consistently with the existing mock.method/mock.fn semantics.

"types": "./dist/index.d.ts",
"exports": {
".": "./src/index.ts",
"./mock_compat": "./src/mock_compat.ts",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Build configuration mismatch — mock_compat.ts won't be built for publish.

The publishConfig.exports expects ./dist/mock_compat.js, but tegg/core/test-util/tsdown.config.ts only defines index: 'src/index.ts' as an entry point. When building for publish, mock_compat.js will not be generated, causing module resolution failures for consumers.

Update tegg/core/test-util/tsdown.config.ts to include mock_compat:

🔧 Proposed fix
 import { defineConfig } from 'tsdown';
 
 export default defineConfig({
   entry: {
     index: 'src/index.ts',
+    mock_compat: 'src/mock_compat.ts',
   },
 });

Also applies to: 38-38

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

In `@tegg/core/test-util/package.json` at line 32, The package export for
"./mock_compat" is declared but the tsdown build config only defines the "index"
entry, so mock_compat.ts will not be built; update
tegg/core/test-util/tsdown.config.ts to add an entry for "mock_compat" (pointing
to "src/mock_compat.ts") alongside the existing "index" entry so the build emits
dist/mock_compat.js and matches the publishConfig.exports; ensure the entry key
name matches the export path ("mock_compat") and that tsdown's entries object
includes both "index": "src/index.ts" and "mock_compat": "src/mock_compat.ts".

Comment on lines +5 to +9
interface MockContext {
method<T extends object>(obj: T, key: keyof T, impl: (...args: any[]) => any): void;
fn(impl?: (...args: any[]) => any): (...args: any[]) => any;
reset(): void;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Missing mock.getter and mock.setter in interface and implementation.

The MockContext interface and Bun polyfill implementation are missing mock.getter (and mock.setter), which are used in tests like EggObject.test.ts line 152. This will cause runtime failures on Bun.

🔧 Proposed fix to add getter support
 interface MockContext {
   method<T extends object>(obj: T, key: keyof T, impl: (...args: any[]) => any): void;
   fn(impl?: (...args: any[]) => any): (...args: any[]) => any;
+  getter<T extends object>(obj: T, key: keyof T, impl: () => any): void;
   reset(): void;
 }

And in the Bun polyfill (around line 29):

     fn(impl?: (...args: any[]) => any): (...args: any[]) => any {
       const calls: any[][] = [];
       const mockFn = (...args: any[]) => {
         calls.push(args);
         return impl?.(...args);
       };
       (mockFn as any).mock = { calls };
       return mockFn;
     },
+    getter<T extends object>(obj: T, key: keyof T, impl: () => any): void {
+      const descriptor = Object.getOwnPropertyDescriptor(obj, key) ||
+        Object.getOwnPropertyDescriptor(Object.getPrototypeOf(obj), key) || {};
+      originals.push({ obj, key, original: descriptor });
+      Object.defineProperty(obj, key, {
+        get: impl,
+        configurable: true,
+      });
+    },
     reset(): void {

Note: The reset logic would also need adjustment to handle property descriptors.

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

In `@tegg/core/test-util/src/mock_compat.ts` around lines 5 - 9, The MockContext
interface and Bun polyfill lack mock.getter and mock.setter causing runtime
failures; add two methods to the MockContext interface named getter(obj: object,
key: PropertyKey, impl: () => any): void and setter(obj: object, key:
PropertyKey, impl: (v: any) => any): void, implement corresponding functions in
the Bun polyfill alongside method, fn, and reset (use
Object.getOwnPropertyDescriptor/Object.defineProperty to replace property
accessors and store original descriptors), and update reset() to restore
original property descriptors rather than only replacing values so
getters/setters are properly cleaned up (refer to MockContext, method, fn, and
reset to find the existing implementation to extend).

Copy link
Copy Markdown
Contributor

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

This PR is a WIP effort to improve Bun runtime compatibility across the Egg.js monorepo by adding targeted runtime guards/shims, adjusting framework/module resolution, and updating tests/CI to account for Bun behavior differences.

Changes:

  • Add a node:test mock compatibility shim and migrate tegg tests/utilities to use it.
  • Patch runtime differences (e.g., V8 callsite API guard, Koa empty-status header stripping, urllib/httpclient timeout enforcement under Bun).
  • Add Bun CI coverage and update/skip tests where Bun currently differs or is blocked by upstream issues.

Reviewed changes

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

Show a summary per file
File Description
tegg/core/test-util/src/mock_compat.ts Adds Bun polyfill for node:test mock API.
tegg/core/test-util/src/EggTestContext.ts Switches to the new mock shim.
tegg/core/test-util/package.json Exposes ./mock_compat via package exports (src + dist).
tegg/core/runtime/test/QualifierLoadUnitInstance.test.ts Uses shimmed mock import.
tegg/core/runtime/test/LoadUnitInstance.test.ts Uses shimmed mock import.
tegg/core/runtime/test/EggObjectUtil.test.ts Uses shimmed mock import.
tegg/core/runtime/test/EggObject.test.ts Uses shimmed mock import.
tegg/core/eventbus-runtime/test/EventBus.test.ts Uses shimmed mock import.
tegg/core/dal-runtime/test/DataSource.test.ts Uses shimmed mock import (and relies on call tracking).
tegg/core/dal-runtime/package.json Adds @eggjs/module-test-util as a devDependency.
tegg/core/common-util/src/StackUtil.ts Guards missing callSite.getScriptHash() on Bun.
tegg/core/aop-runtime/test/aop-runtime.test.ts Uses shimmed mock import.
pnpm-lock.yaml Lockfile update for workspace/importer changes.
packages/utils/test/import.test.ts Loosens error-message matching for Bun/Node variance.
packages/utils/src/framework.ts Adds pnpm virtual-store lookup to framework resolution.
packages/supertest/test/supertest.test.ts Skips/adjusts assertions for Bun HTTP/HTTPS behavior differences.
packages/koa/test/response/status.test.ts Adjusts empty-status header expectations under Bun.
packages/koa/test/application/response.test.ts Skips http2-specific mutation test under Bun.
packages/koa/src/application.ts Explicitly removes content headers on empty status responses.
packages/egg/test/lib/plugins/multipart.test.ts Skips multipart test suite on Bun.
packages/egg/test/lib/core/messenger/local.test.ts Converts callback-style assertion to async/promise style.
packages/egg/test/lib/core/dnscache_httpclient.test.ts Skips dnscache httpclient tests on Bun.
packages/egg/test/cluster1/app_worker.test.ts Skips a request-packet test on Bun.
packages/egg/test/app/middleware/meta.test.ts Skips keep-alive header expectation on Bun.
packages/egg/test/app/middleware/body_parser.test.ts Skips invalid-gzip test on Bun (hang issue).
packages/egg/test/app/extend/response.test.ts Skips rawHeaders case-sensitivity assertion on Bun.
packages/egg/test/agent.test.ts Skips IPC/log timing-dependent tests on Bun.
packages/egg/src/lib/core/httpclient.ts Adds Bun-specific timeout enforcement via AbortSignal.timeout.
packages/core/test/loader/mixin/load_extend.test.ts Loosens module/package error matching for Bun variance.
packages/core/test/loader/file_loader.test.ts Loosens class-constructor error matching for Bun variance.
packages/cluster/test/options.test.ts Adapts assertions/paths and adds pnpm virtual-store expectation.
packages/cluster/test/https.test.ts Skips HTTPS suite on Bun due to certificate handling differences.
.gitignore Ignores Bun runtime cache directory.
.github/workflows/ci.yml Adds test-bun job (ubuntu + macOS, sharded) and gates merge on it.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

Comment on lines +5 to +35
interface MockContext {
method<T extends object>(obj: T, key: keyof T, impl: (...args: any[]) => any): void;
fn(impl?: (...args: any[]) => any): (...args: any[]) => any;
reset(): void;
}

let mockCompat: MockContext;

if (process.versions.bun) {
const originals: Array<{ obj: any; key: string | symbol; original: any }> = [];

mockCompat = {
method<T extends object>(obj: T, key: keyof T, impl: (...args: any[]) => any): void {
originals.push({ obj, key, original: obj[key] });
(obj as any)[key] = impl;
},
fn(impl?: (...args: any[]) => any): (...args: any[]) => any {
const calls: any[][] = [];
const mockFn = (...args: any[]) => {
calls.push(args);
return impl?.(...args);
};
(mockFn as any).mock = { calls };
return mockFn;
},
reset(): void {
for (const { obj, key, original } of originals) {
obj[key] = original;
}
originals.length = 0;
},
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 polyfill branch, mockCompat.method() currently returns void and only assigns obj[key] = impl. Several tests rely on the real node:test behavior where mock.method() returns a tracker with .mock.callCount() (e.g. const tracker = mock.method(...); tracker.mock.callCount()). This will throw under Bun. Also, reset() replays originals in insertion order, which fails to fully restore when the same method is mocked multiple times in one test (the last restore wins and can leave the method mocked). Update the polyfill so method() returns a tracker compatible with existing test usage and ensure reset restores the true original (e.g. restore in reverse order and/or dedupe per obj+key).

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +66
const rawTimeout = options.timeout ?? this.#app.config.httpclient?.request?.timeout;
// urllib supports timeout as number or [connectTimeout, responseTimeout].
// Use the shorter (connect) timeout for AbortSignal — if headers haven't
// arrived within connectTimeout the request should fail, matching Node's
// headersTimeout semantics as closely as possible.
const timeoutMs = Array.isArray(rawTimeout)
? Math.min(...rawTimeout.filter((t): t is number => typeof t === 'number' && t > 0))
: rawTimeout;
if (typeof timeoutMs === 'number' && timeoutMs > 0) {
options.signal = AbortSignal.timeout(timeoutMs);
try {
return await super.request<T>(url, options);
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.

When rawTimeout is an array, Math.min(...rawTimeout.filter(...)) will produce Infinity if the filtered array is empty (e.g. [0, 0], [], or all non-positive values). That would pass the typeof timeoutMs === 'number' && timeoutMs > 0 check and create AbortSignal.timeout(Infinity), which can behave unexpectedly (clamping or never timing out depending on runtime). Consider explicitly handling the “no valid positive timeout” case by checking the filtered array length before calling Math.min().

Copilot uses AI. Check for mistakes.
Comment on lines +84 to +87
for (const dir of [initCwd, baseDir]) {
const pnpmVirtualDir = path.join(dir, 'node_modules/.pnpm/node_modules');
if (existsSync(pnpmVirtualDir)) {
moduleDirs.add(pnpmVirtualDir);
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 added pnpm virtual-store lookup is described as a Bun-specific workaround, but it currently runs unconditionally for all runtimes. If this lookup is only needed for Bun, consider guarding it with process.versions.bun to avoid subtly changing framework resolution/error output on Node.js. If it’s intentionally runtime-agnostic, the comment should be updated to reflect that and explain why it’s safe/desired on Node as well.

Suggested change
for (const dir of [initCwd, baseDir]) {
const pnpmVirtualDir = path.join(dir, 'node_modules/.pnpm/node_modules');
if (existsSync(pnpmVirtualDir)) {
moduleDirs.add(pnpmVirtualDir);
if ('bun' in process.versions) {
for (const dir of [initCwd, baseDir]) {
const pnpmVirtualDir = path.join(dir, 'node_modules/.pnpm/node_modules');
if (existsSync(pnpmVirtualDir)) {
moduleDirs.add(pnpmVirtualDir);
}

Copilot uses AI. Check for mistakes.
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.

2 participants