-
-
Notifications
You must be signed in to change notification settings - Fork 21
Description
π Migrate to Bun Runtime
Summary
Migrate MCP Rubber Duck from Node.js/npm to Bun as the primary runtime for faster builds, tests, and development experience.
Approach: Hybrid migration - Bun for runtime/build/test, Node.js for npm publish (due to missing provenance support and auth bugs in CI).
Motivation
- Speed: Bun installs packages 20-40x faster than npm
- Native TypeScript: No need for
tsxorts-node - Faster tests:
bun testis significantly faster than Jest with ESM - Simpler tooling: One tool for package management, bundling, and testing
β οΈ Known Issues & Required Workarounds
1. Winston File Transport Broken in Bun 1.2.x
Issue: oven-sh/bun#19090
- Affected: Bun 1.2.0+ (works in 1.1.45 and lower)
- Symptom:
ENOENT: no such file or directory, mkdirin_createLogDirIfNotExist() - Workaround: Add explicit
dirnameoption to all File transports
Our project uses: src/utils/logger.ts with multiple File transports
Fix required: Add dirname: logsDir to each transport:
new winston.transports.File({
filename: `${filePrefix}-error.log`,
dirname: logsDir, // ADD THIS
level: 'error',
// ...
})2. mock.module() Does NOT Hoist
Issue: Bun Docs
- Jest's
jest.mock()is hoisted to top of file - Bun's
mock.module()runs in-place at runtime - Impact: 36 test files need restructuring
Workaround: Use --preload for common mocks OR use dynamic imports after mock.module()
3. mock.restore() Does NOT Work
Issue: oven-sh/bun#7823
- When mocking modules, they are patched in-place
- You cannot restore the actual module
- Impact: Test isolation may be affected
4. Zod Installation Bug in Bun 1.3.4
Issue: oven-sh/bun#25492
- Installing Zod with
bun install zodcauses unterminated strings - Imports appear as
"zod/v4/coreinstead of"zod/v4/core" - Workaround: Use
npm install zodOR pin to Bun version < 1.3.4
5. semantic-release Does NOT Run on Bun
Issue: semantic-release#3527
- Closed as "not planned"
- Solution: Keep Node.js for semantic-release step (already in our plan)
6. child_process IPC Differences
Issue: Bun Docs - Spawn
- Bun uses different JavaScript engine serialization
- For Bun β Node IPC: Must use
serialization: "json" - MCP SDK uses StdioClientTransport which spawns child processes
7. Bun Auto-Loads .env (Unlike dotenv)
Issue: Bun Docs - Environment Variables
- Bun automatically reads
.envfiles - This can conflict with manual
dotenvloading - Our project: Uses
dotenv- may need to remove it or use--no-env-file
8. OpenAI Streaming in Production Builds
Issue: oven-sh/bun#25630
- Network errors when streaming responses in Bun production builds
- Affects Vercel AI SDK's
streamText - Our project: Uses OpenAI SDK streaming - needs testing
9. beforeAll Hook Order Bug
Issue: oven-sh/bun#21830
beforeAlldoesn't always run right before tests in nested describe blocks- Workaround: Use
onTestFinished(fn)for cleanup (added in Bun v1.3.2)
10. ESM .mjs Resolution Issues
Issue: oven-sh/bun#18584, #12471
- TypeScript
.mtsfiles may not resolve correctly from.mjsimports - Our project: Uses
.jsextensions - should be fine
11. bun publish Authentication Fails in GitHub Actions
Issue: oven-sh/bun#24124
- Even with valid
.npmrc,bun publishfails with "missing authentication" - Workaround: Use
npm publishinstead (already in our hybrid plan)
12. GitHub Actions Cache Issues
Issue: oven-sh/setup-bun#78
- Caching can be "unsatisfactory in monorepo setting"
- Note: Maintainers say
bun installis often faster than cache restore
13. Bun Does NOT Generate TypeScript Declaration Files
Issue: oven-sh/bun#5141
bun buildcannot generate.d.tsfiles- Workaround: Use
tsc --emitDeclarationOnlyalongsidebun build - Our project: Already uses
tscfor build, so this is fine
14. bun publish Loses package.json Metadata
Issue: oven-sh/bun#14633
bun publishdoes not preservehomepage,license,repositoryfields- npm registry sidebar shows "license: none" and empty links
- Workaround: Use
npm publish(already in our hybrid plan)
15. Postinstall Scripts Blocked by Default
Issue: Bun Docs - Trusted Dependencies
- Bun blocks postinstall scripts for security
- Packages like Prisma, Puppeteer, Playwright may break silently
- Our project: No dependencies with critical postinstall scripts
- If needed: Add to
trustedDependenciesin package.json or usebun add --trust
16. Bun 1.3 Breaking Changes
Issue: oven-sh/bun#20292
Bun.serve()TypeScript types reworked- Defaults to
"module": "Preserve"instead of auto-detection - Our project: Not using
Bun.serve(), minimal impact
17. ESLint Plugin Resolution Issues with Bun
Issue: oven-sh/bun#5128
- ESLint may not find
@typescript-eslintplugin when installed with Bun - Workaround: If linting fails, run
npm installinstead ofbun installfor ESLint deps - Alternative: Run ESLint with
bun --bun eslint .instead ofbun run lint - Our project: Uses
@typescript-eslint- test this early
18. Avoid Bun-Specific APIs for Portability
Reference: DEV.to - Why using Bun in production maybe isnt the best idea
- Bun ships non-standard APIs:
Bun.file,Bun.serve,Bun.YAML.stringify - Using these creates vendor lock-in and makes migration back to Node.js difficult
- Recommendation: Stick to Node.js-compatible APIs for portability
- Our project: Uses standard Node.js APIs - good for portability
Research Sources
- Bun Test Runner
- Bun Mocks
- Bun Publish
- Bun Docker
- Bun Lockfile
- oven-sh/setup-bun
- Migrate from Jest
- Winston + Bun Issue
- Zod + Bun Issue
Dependency Compatibility Matrix
| Dependency | Version | Bun Compatible? | Notes |
|---|---|---|---|
@modelcontextprotocol/sdk |
^1.24.0 | StdioClientTransport uses child_process | |
openai |
^4.0.0 | β Yes | Official support for Bun 1.0+ |
dotenv |
^16.4.0 | Bun auto-loads .env - may conflict | |
zod |
^3.23.0 | Use npm to install, not bun | |
winston |
^3.11.0 | Add dirname to all File transports |
|
node-cache |
^5.1.2 | β Yes | Uses standard Node APIs |
ajv |
^8.17.1 | β Yes | Pure JS library |
jest |
(remove) | N/A | Replace with bun:test |
ts-jest |
(remove) | N/A | Bun has native TS |
tsx |
(remove) | N/A | Bun has native TS |
Recommended Bun Version
Use Latest Stable Bun with workarounds:
- Apply Winston
dirnameworkaround (required for Bun 1.2.x+) - Avoid
bun install zod(usenpm install zodthenbun install) - Test MCP SDK thoroughly
Installation sequence:
# 1. First install zod with npm (to avoid Bun 1.3.4 bug)
npm install zod
# 2. Then run bun install for everything else
bun installPhase 1: Package Manager Migration
1.1 Generate Bun lockfile (IMPORTANT: Follow exact sequence)
# Step 1: Remove old lockfile and node_modules
rm -rf node_modules package-lock.json
# Step 2: Install zod with npm FIRST (to avoid Bun 1.3.4 bug)
npm install zod --package-lock-only
# Step 3: Run bun install (auto-migrates package-lock.json to bun.lock)
bun install
# Creates bun.lock (text-based format since Bun v1.2)
# Step 4: Delete package-lock.json (we use bun.lock now)
rm package-lock.jsonWhy this sequence?
- Bun 1.3.4 has a bug where
bun install zodcreates corrupted files - Installing zod via npm first, then letting bun migrate, avoids the bug
1.2 Update package.json scripts
{
"scripts": {
"build": "tsc",
"dev": "bun --watch src/index.ts",
"start": "bun dist/index.js",
"test": "bun test",
"test:watch": "bun test --watch",
"test:coverage": "bun test --coverage --coverage-reporter=lcov",
"lint": "eslint src --ext .ts",
"format": "prettier --write \"src/**/*.ts\"",
"typecheck": "tsc --noEmit"
}
}1.3 Update dependencies
Remove:
tsx(Bun has native TypeScript)jest,ts-jest,@types/jest
Add:
@types/bun(for TypeScript support)
Keep:
typescript,eslint,prettier- All runtime dependencies unchanged
1.4 Create bunfig.toml
[test]
preload = ["./tests/setup.ts"]
coverageReporter = ["text", "lcov"]
coverageDir = "coverage"
[install]
# Ensure lockfile is used
frozen-lockfile = falsePhase 2: Test Migration (36 files)
2.1 Critical Difference: Mock Hoisting
Jest: jest.mock() is hoisted to top of file
Bun: mock.module() is NOT hoisted - must use --preload
2.2 Create test setup preload file
Create tests/setup.ts:
import { mock } from 'bun:test';
// Mock logger globally (was jest.mock in every file)
mock.module('../src/utils/logger', () => ({
logger: {
info: () => {},
error: () => {},
warn: () => {},
debug: () => {},
},
}));2.3 Migration patterns
| Jest | Bun:test |
|---|---|
import { jest } from '@jest/globals' |
import { mock, spyOn } from 'bun:test' |
jest.fn() |
mock() or jest.fn() (both work) |
jest.mock('module') |
mock.module('module', () => ({})) |
jest.clearAllMocks() |
mock.clearAllMocks() |
jest.Mocked<T> |
No equivalent - use as unknown as |
mockFn.mockResolvedValue(v) |
Same API - works identically |
mockFn.mockImplementation(fn) |
Same API - works identically |
2.4 Example test conversion
Before (Jest):
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
const mockCreate = jest.fn();
jest.mock('openai', () => ({
default: jest.fn().mockImplementation(() => ({
chat: { completions: { create: mockCreate } }
})),
}));
import { DuckProvider } from '../src/providers/provider';After (Bun):
import { describe, it, expect, mock, beforeEach } from 'bun:test';
const mockCreate = mock();
mock.module('openai', () => ({
default: mock(() => ({
chat: { completions: { create: mockCreate } }
})),
}));
// Import AFTER mock.module (or use preload)
const { DuckProvider } = await import('../src/providers/provider');2.5 Type workaround for jest.Mocked<T>
// Create utility type
type MockedObject<T> = {
[K in keyof T]: T[K] extends (...args: any[]) => any
? ReturnType<typeof mock<T[K]>>
: T[K];
};
// Usage
let mockProviderManager: MockedObject<ProviderManager>;2.6 Files requiring special attention
Files with complex mocking patterns:
tests/providers.test.ts- Heavyjest.mock()usage, mock before import patterntests/tools/*.test.ts- Multiple module mockstests/mcp-bridge.test.ts- Service mocking
Phase 3: Dockerfile Migration
3.1 ARM64 + Alpine is now supported
Bun v1.1.35+ has native musl support. We can use oven/bun:alpine for both amd64 and arm64.
3.2 New Dockerfile
# Multi-stage build for optimal size
FROM oven/bun:1-alpine AS builder
WORKDIR /app
# Copy package files
COPY package.json bun.lock ./
# Install dependencies
RUN bun install --frozen-lockfile
# Copy source code
COPY . .
# Build the application
RUN bun run build
# Production stage
FROM oven/bun:1-alpine
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
# Create app user
RUN addgroup -g 1001 -S bunjs && \
adduser -S bunjs -u 1001
WORKDIR /app
# Copy package files
COPY package.json bun.lock ./
# Copy production node_modules from builder
COPY --from=builder /app/node_modules ./node_modules
# Copy built application
COPY --from=builder /app/dist ./dist
# Copy configuration examples
COPY config/config.example.json ./config/
# Change ownership
RUN chown -R bunjs:bunjs /app
# Switch to non-root user
USER bunjs
# Healthcheck
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=30s \
CMD bun -e "process.exit(0)" || exit 1
# Use dumb-init
ENTRYPOINT ["dumb-init", "--"]
# Start with Bun
CMD ["bun", "dist/index.js"]Phase 4: CI/CD Migration
4.1 Hybrid Approach for Provenance
Problem: bun publish --provenance doesn't exist.
Solution: Use Bun for install/test/build, npm for publish.
4.2 Updated semantic-release.yml
jobs:
test:
name: π§ͺ Test Before Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: π¦ Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: π₯ Install dependencies
run: bun install --frozen-lockfile
- name: π Lint
run: bun run lint
- name: ποΈ Build
run: bun run build
- name: π¬ Type check
run: bun run typecheck
- name: π§ͺ Test
run: bun test
release:
name: π¦ Release
needs: [test, security-scan, dependency-check, dockerfile-lint]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Need BOTH Bun and Node for hybrid approach
- name: π¦ Setup Bun
uses: oven-sh/setup-bun@v2
- name: π¦ Setup Node.js (for npm publish with provenance)
uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: π₯ Install dependencies
run: bun install --frozen-lockfile
- name: ποΈ Build
run: bun run build
# semantic-release runs with Node (required for plugins)
- name: π¦ Run semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx semantic-release
# npm publish requires Node for --provenance
- name: π€ Publish to npm with provenance
if: steps.check-release.outputs.released == 'true'
run: npm publish --provenance --access public4.3 Updated security.yml
test:
name: π§ͺ Test Suite
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: π¦ Setup Bun
uses: oven-sh/setup-bun@v2
- name: π₯ Install dependencies
run: bun install --frozen-lockfile
- name: π Lint
run: bun run lint
- name: ποΈ Build
run: bun run build
- name: π¬ Type check
run: bun run typecheck
- name: π§ͺ Test
run: bun test
dependency-check:
# Keep npm for audit (Bun audit is less mature)
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci
- run: npm audit --audit-level=moderate || true
- run: npx audit-ci --config audit-ci.jsonPhase 5: Documentation Updates
5.1 Update CLAUDE.md
## Common Commands
\`\`\`bash
bun install # Install dependencies
bun run build # Build TypeScript to dist/
bun run dev # Development with watch mode
bun test # Run all tests
bun test tests/foo.test.ts # Single test file
bun test --test-name-pattern="pattern" # Filter by name
bun run lint # ESLint
bun run format # Prettier
bun run typecheck # Type check without emit
\`\`\`5.2 Update README.md
- Change npm references to Bun
- Update installation instructions
- Note hybrid approach for contributors
Files to Modify
| File | Action | Notes |
|---|---|---|
package.json |
Edit | Update scripts, remove Jest deps, add @types/bun |
package-lock.json |
Delete | Replace with bun.lock |
bun.lock |
Create | Auto-generated |
bunfig.toml |
Create | Test config |
jest.config.js |
Delete | No longer needed |
tests/setup.ts |
Create | Preload file for mocks |
tests/*.test.ts (36 files) |
Edit | Migrate Jest β bun:test |
Dockerfile |
Edit | Switch to oven/bun:alpine |
.github/workflows/semantic-release.yml |
Edit | Hybrid Bun + Node |
.github/workflows/security.yml |
Edit | Use Bun for tests |
README.md |
Edit | Update commands |
CLAUDE.md |
Edit | Update commands |
Verification Checklist
-
Installation
rm -rf node_modules package-lock.json bun install # Should create bun.lock -
Build
bun run build # Should compile TypeScript to dist/ -
Tests
bun test # All 36 test files should pass bun test --coverage # Coverage report should generate
-
Dev mode
bun run dev # Should start with watch mode -
Docker
docker build -t mcp-rubber-duck:bun . docker run --rm mcp-rubber-duck:bun # Should start successfully
-
MCP tools (manual)
- Configure in Claude Desktop
- Test
ask_duck,list_ducks, etc.
-
CI (push to branch)
- All GitHub Actions should pass
Risks & Mitigations
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
mock.module() not hoisting |
High | Medium | Use --preload for global mocks |
| MCP SDK compatibility | Low | High | Test stdio transport thoroughly |
| semantic-release plugins | Medium | Medium | Keep Node for semantic-release |
| npm audit in CI | Low | Low | Keep npm for audit step |
Rollback Plan
- Keep
backup/node-setupbranch with current Node.js configuration - If critical issues found, revert to Node.js branch
- Document any Bun-specific issues encountered
Appendix: Specific Code Fixes Required
A1. Winston File Transport Fix (src/utils/logger.ts)
Current code (broken in Bun 1.2.x):
logger.add(
new winston.transports.File({
filename: join(logsDir, `${filePrefix}-error.log`),
level: 'error',
// ...
})
);Fixed code:
logger.add(
new winston.transports.File({
filename: `${filePrefix}-error.log`,
dirname: logsDir, // REQUIRED for Bun 1.2.x
level: 'error',
// ...
})
);Apply to all 3 File transports in logger.ts.
A2. Remove dotenv (Bun auto-loads .env)
Current (src/index.ts or similar):
import 'dotenv/config';
// OR
import dotenv from 'dotenv';
dotenv.config();After migration:
// Remove dotenv import entirely
// Bun automatically loads .env filesAlternatively, use --no-env-file flag if you need manual control.
A3. Test Preload File (tests/setup.ts)
Create this file for global mocks:
import { mock } from 'bun:test';
// Mock logger globally (replaces jest.mock in every file)
mock.module('../src/utils/logger', () => ({
logger: {
info: () => {},
error: () => {},
warn: () => {},
debug: () => {},
},
}));
// Mock any other commonly mocked modules hereA4. Test Migration Pattern for Complex Mocks
Before (Jest):
import { jest } from '@jest/globals';
const mockCreate = jest.fn();
jest.mock('openai', () => ({
default: jest.fn().mockImplementation(() => ({
chat: { completions: { create: mockCreate } }
})),
}));
import { DuckProvider } from '../src/providers/provider';After (Bun) - Option 1: Dynamic Import:
import { mock, describe, it, expect, beforeEach } from 'bun:test';
const mockCreate = mock();
mock.module('openai', () => ({
default: mock(() => ({
chat: { completions: { create: mockCreate } }
})),
}));
// Use dynamic import AFTER mock.module
const { DuckProvider } = await import('../src/providers/provider');After (Bun) - Option 2: Preload + Static Import:
// In tests/mocks/openai.ts (preloaded)
import { mock } from 'bun:test';
export const mockCreate = mock();
mock.module('openai', () => ({
default: mock(() => ({
chat: { completions: { create: mockCreate } }
})),
}));
// In test file
import { describe, it, expect, beforeEach } from 'bun:test';
import { mockCreate } from './mocks/openai';
import { DuckProvider } from '../src/providers/provider';A5. bunfig.toml with Preload
[test]
preload = [
"./tests/setup.ts",
"./tests/mocks/openai.ts"
]
coverageReporter = ["text", "lcov"]
coverageDir = "coverage"
root = "."
[install]
# Use frozen lockfile in CI
# frozen-lockfile = trueA6. Type Workaround for jest.Mocked
Since Bun doesn't have jest.Mocked<T>, create a utility:
// tests/types.ts
import type { Mock } from 'bun:test';
export type MockedFunction<T extends (...args: any[]) => any> = Mock<T>;
export type MockedObject<T> = {
[K in keyof T]: T[K] extends (...args: any[]) => any
? Mock<T[K]>
: T[K];
};Usage:
import type { MockedObject } from './types';
import type { ProviderManager } from '../src/providers/manager';
let mockProviderManager: MockedObject<ProviderManager>;Pre-Implementation Checklist
Before starting implementation, verify:
- Bun version decision: 1.1.45 (stable) OR 1.2.x+ (with workarounds)
- Test MCP SDK stdio transport works in Bun
- Confirm OpenAI streaming works in Bun runtime
- Verify zod is installed via npm (not bun)
- Winston dirname fix is understood
- Test migration strategy decided (preload vs dynamic imports)
Implementation Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Bun version | Latest stable | Apply workarounds for known issues |
| dotenv | Remove | Bun auto-loads .env; MCP config provides API keys |
| Test mocking | Preload files | Best practice for module mocking in Bun |
| npm publish | Keep via Node | bun publish has auth bugs + no provenance |
| Docker | oven/bun:alpine | ARM64 supported since Bun 1.1.35 |
Task Checklist
Phase 1: Package Manager Migration
- Remove
package-lock.json - Install zod via npm first (workaround for #25492)
- Run
bun installto generatebun.lock - Update
package.jsonscripts - Remove
tsx,jest,ts-jest,@types/jestdependencies - Add
@types/bundependency - Remove
dotenvdependency - Create
bunfig.toml
Phase 2: Source Code Fixes
- Fix Winston File Transport (
src/utils/logger.ts) - adddirnameoption - Remove dotenv import (
src/config/config.ts) - Verify MCP SDK stdio transport works with Bun
Phase 3: Test Migration (36 files)
- Create
tests/setup.tspreload file - Create
tests/mocks/openai.tspreload file - Create
tests/types.tswithMockedObject<T>utility - Delete
jest.config.js - Migrate all test files from Jest to bun:test
-
tests/approval.test.ts -
tests/ascii-art.test.ts -
tests/cache.test.ts -
tests/config.test.ts -
tests/consensus.test.ts -
tests/conversation.test.ts -
tests/duck-debate.test.ts -
tests/duck-iterate.test.ts -
tests/duck-judge.test.ts -
tests/duck-vote.test.ts -
tests/health.test.ts -
tests/mcp-bridge.test.ts -
tests/pricing.test.ts -
tests/prompts.test.ts -
tests/providers.test.ts -
tests/safe-logger.test.ts -
tests/tool-annotations.test.ts -
tests/usage.test.ts -
tests/guardrails/*.test.ts(6 files) -
tests/tools/*.test.ts(12 files)
-
Phase 4: Docker Migration
- Update Dockerfile to use
oven/bun:1-alpine - Test multi-platform builds (amd64, arm64)
Phase 5: CI/CD Migration
- Update
.github/workflows/semantic-release.yml- Add
oven-sh/setup-bun@v2 - Keep
actions/setup-node@v4for npm publish - Update install/build/test commands
- Add
- Update
.github/workflows/security.yml- Use Bun for test job
- Keep npm for dependency audit
- Test all workflows pass
Phase 6: Documentation
- Update
README.md - Update
CLAUDE.md
Phase 7: Verification
-
bun installworks -
bun run buildcompiles TypeScript -
bun testpasses all tests -
bun run devstarts server - Docker build succeeds
- MCP tools work in Claude Desktop
- GitHub Actions pass
Environment Variable Handling
The project uses environment variables for API keys and configuration:
| Context | How env vars are provided |
|---|---|
| MCP server | Claude Desktop MCP config |
| Local dev | Bun auto-loads .env files |
| Docker | --env-file or -e flags |
| CI | GitHub Actions secrets |
Loading order: .env < .env.production|development|test < .env.local