Skip to content

πŸš€ Migrate to Bun RuntimeΒ #41

@nesquikm

Description

@nesquikm

πŸš€ 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 tsx or ts-node
  • Faster tests: bun test is 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, mkdir in _createLogDirIfNotExist()
  • Workaround: Add explicit dirname option 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 zod causes unterminated strings
  • Imports appear as "zod/v4/core instead of "zod/v4/core"
  • Workaround: Use npm install zod OR 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 .env files
  • This can conflict with manual dotenv loading
  • 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

  • beforeAll doesn'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 .mts files may not resolve correctly from .mjs imports
  • Our project: Uses .js extensions - should be fine

11. bun publish Authentication Fails in GitHub Actions

Issue: oven-sh/bun#24124

  • Even with valid .npmrc, bun publish fails with "missing authentication"
  • Workaround: Use npm publish instead (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 install is often faster than cache restore

13. Bun Does NOT Generate TypeScript Declaration Files

Issue: oven-sh/bun#5141

  • bun build cannot generate .d.ts files
  • Workaround: Use tsc --emitDeclarationOnly alongside bun build
  • Our project: Already uses tsc for build, so this is fine

14. bun publish Loses package.json Metadata

Issue: oven-sh/bun#14633

  • bun publish does not preserve homepage, license, repository fields
  • 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 trustedDependencies in package.json or use bun 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-eslint plugin when installed with Bun
  • Workaround: If linting fails, run npm install instead of bun install for ESLint deps
  • Alternative: Run ESLint with bun --bun eslint . instead of bun 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


Dependency Compatibility Matrix

Dependency Version Bun Compatible? Notes
@modelcontextprotocol/sdk ^1.24.0 ⚠️ Needs testing StdioClientTransport uses child_process
openai ^4.0.0 βœ… Yes Official support for Bun 1.0+
dotenv ^16.4.0 ⚠️ Redundant Bun auto-loads .env - may conflict
zod ^3.23.0 ⚠️ Install bug Use npm to install, not bun
winston ^3.11.0 ⚠️ File transport bug 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 dirname workaround (required for Bun 1.2.x+)
  • Avoid bun install zod (use npm install zod then bun 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 install

Phase 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.json

Why this sequence?

  • Bun 1.3.4 has a bug where bun install zod creates 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 = false

Phase 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 - Heavy jest.mock() usage, mock before import pattern
  • tests/tools/*.test.ts - Multiple module mocks
  • tests/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 public

4.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.json

Phase 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

  1. Installation

    rm -rf node_modules package-lock.json
    bun install
    # Should create bun.lock
  2. Build

    bun run build
    # Should compile TypeScript to dist/
  3. Tests

    bun test
    # All 36 test files should pass
    bun test --coverage
    # Coverage report should generate
  4. Dev mode

    bun run dev
    # Should start with watch mode
  5. Docker

    docker build -t mcp-rubber-duck:bun .
    docker run --rm mcp-rubber-duck:bun
    # Should start successfully
  6. MCP tools (manual)

    • Configure in Claude Desktop
    • Test ask_duck, list_ducks, etc.
  7. 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

  1. Keep backup/node-setup branch with current Node.js configuration
  2. If critical issues found, revert to Node.js branch
  3. 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 files

Alternatively, 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 here

A4. 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 = true

A6. 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 install to generate bun.lock
  • Update package.json scripts
  • Remove tsx, jest, ts-jest, @types/jest dependencies
  • Add @types/bun dependency
  • Remove dotenv dependency
  • Create bunfig.toml

Phase 2: Source Code Fixes

  • Fix Winston File Transport (src/utils/logger.ts) - add dirname option
  • Remove dotenv import (src/config/config.ts)
  • Verify MCP SDK stdio transport works with Bun

Phase 3: Test Migration (36 files)

  • Create tests/setup.ts preload file
  • Create tests/mocks/openai.ts preload file
  • Create tests/types.ts with MockedObject<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@v4 for npm publish
    • Update install/build/test commands
  • 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 install works
  • bun run build compiles TypeScript
  • bun test passes all tests
  • bun run dev starts 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions