Compare commits

..

15 Commits

Author SHA1 Message Date
autofix-ci[bot]
b60b3cbef1 [autofix.ci] apply automated fixes 2025-09-13 04:48:26 +00:00
Claude Bot
57358d3062 feat: improve virtual module support in bundler plugins
- Support async factories that return Promises
- Support Uint8Array/typed arrays for module contents
- Support loader: 'object' with JSON serialization (parity with onLoad)
- Fix registration order to ensure native state updates before JS map
- Fix namespace check for virtual modules (only match file/empty namespace)
- Fix virtual module resolution to work without onResolve filters
- Change VirtualModuleMap from pointer to value type for better memory management

All tests passing for bundler-plugin-virtual-modules.test.ts and bun-build-api.test.ts

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 04:45:42 +00:00
Claude Bot
8557ca1b87 Merge branch 'main' into claude/add-virtual-modules-support 2025-09-13 04:25:47 +00:00
autofix-ci[bot]
a07758190a [autofix.ci] apply automated fixes 2025-09-10 02:09:24 +00:00
Claude Bot
2185da0931 Address remaining PR review comments
- Use 'file' namespace consistently instead of empty string for virtual modules
- Add idempotency check to prevent duplicate virtual module registration
- Add test for duplicate registration prevention
- Fix Map method usage (use .has/.get/.set instead of //)

Native cleanup (tombstone) happens automatically when the plugin is destroyed,
so no manual tombstone call is needed from JavaScript.

All tests pass successfully.
2025-09-10 02:06:41 +00:00
autofix-ci[bot]
2127a3cb6c [autofix.ci] apply automated fixes 2025-09-10 01:07:26 +00:00
Claude Bot
49be23280a Fix module() API test to properly import virtual module 2025-09-10 01:04:55 +00:00
Claude Bot
52c138ef15 Address PR review comments
- Add test for virtual module as entrypoint (requested by alii)
- Include module name in error messages for better debugging (requested by alii)
- Check outputs in bun-build-api test to verify virtual module content (requested by alii)
- Use intrinsic argument validation (, ) for consistency
- Guard against undefined onResolve map to prevent crashes

All tests continue to pass with these improvements.
2025-09-10 01:03:07 +00:00
Claude Bot
c605abc61c Fix remaining compilation errors
- Make BundlerPlugin::visitAdditionalChildren templated to support AbstractSlotVisitor
- Move template implementation to header file for proper instantiation
- All tests now pass successfully
2025-09-09 21:54:05 +00:00
Claude Bot
5f612dd2ba Fix compilation errors and address PR review feedback
- Add missing root.h include at the beginning of JSBundlerPlugin.cpp
- Fix WriteBarrierList usage in visitAdditionalChildren (pass cell parameter)
- Remove invalid clear() call on WriteBarrierList in tombstone()
- Fix namespace comment in JSBundlerPlugin.h
- Default virtual module loader to 'js' instead of defaultLoader
- Make test filter more specific to avoid unintended matches
- Use regex for error matching in tests to be more robust
2025-09-09 20:52:43 +00:00
autofix-ci[bot]
674759309e [autofix.ci] apply automated fixes 2025-09-08 23:29:51 +00:00
Claude Bot
23be320484 test: Update module() test to verify it's now supported
The test previously expected module() to throw because it wasn't implemented.
Now that build.module() is implemented, update the test to verify it works correctly.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 23:20:50 +00:00
Claude Bot
3add6b1756 fix: Clear virtual modules on plugin cleanup to prevent memory leaks
- Clear C++ VirtualModuleMap when plugin is tombstoned
- Clear JavaScript virtualModules Map in runOnEndCallbacks
- Ensures virtual module callbacks are properly garbage collected after build

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 23:20:38 +00:00
Claude Bot
7059672379 test: Add additional tests for virtual modules
- Add test to verify onLoad plugins still work alongside virtual modules
- Add test to verify no memory leaks with repeated builds
- Tests ensure virtual modules don't interfere with regular plugin functionality

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 23:20:27 +00:00
Claude Bot
aabccfc731 feat: Add build.module() support for Bun.build plugins
Implements virtual module support for Bun.build plugins, allowing plugins to define
modules that can be imported like regular files without creating actual files on disk.

- Added VirtualModuleMap to JSBundlerPlugin for storing virtual modules
- Implemented build.module(specifier, callback) in plugin setup
- Virtual modules are resolved and loaded through the standard plugin pipeline
- Added comprehensive test suite covering various use cases

```js
await Bun.build({
  entrypoints: ["./entry.ts"],
  plugins: [{
    name: "my-plugin",
    setup(build) {
      build.module("my-virtual-module", () => ({
        contents: "export default 'Hello!'",
        loader: "js",
      }));
    }
  }],
});
```

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 23:20:06 +00:00
14 changed files with 810 additions and 732 deletions

View File

@@ -1,141 +0,0 @@
name: Docker Security Updates
on:
schedule:
# Run weekly on Mondays at 2 AM UTC
- cron: '0 2 * * 1'
workflow_dispatch:
pull_request:
paths:
- 'dockerhub/**'
- '.github/workflows/docker-security.yml'
jobs:
update-base-images:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'oven-sh' }}
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/actions/setup-bun
with:
bun-version: "latest"
- name: Check for base image updates
id: check-updates
run: |
#!/bin/bash
set -e
echo "Checking for base image updates..."
UPDATES_NEEDED=false
UPDATE_MESSAGE=""
# Check Debian bookworm latest
DEBIAN_LATEST=$(docker run --rm debian:bookworm-slim cat /etc/debian_version 2>/dev/null || echo "unknown")
echo "Latest Debian bookworm version: $DEBIAN_LATEST"
# Check Alpine latest
ALPINE_CURRENT=$(grep -oP 'alpine:\K[0-9.]+' dockerhub/alpine/Dockerfile | head -1)
ALPINE_LATEST=$(docker run --rm alpine:latest cat /etc/alpine-release 2>/dev/null | cut -d. -f1,2)
echo "Current Alpine version in Dockerfile: $ALPINE_CURRENT"
echo "Latest Alpine version: $ALPINE_LATEST"
if [ "$ALPINE_CURRENT" != "$ALPINE_LATEST" ]; then
echo "Alpine update available: $ALPINE_CURRENT -> $ALPINE_LATEST"
UPDATES_NEEDED=true
UPDATE_MESSAGE="${UPDATE_MESSAGE}Alpine: $ALPINE_CURRENT -> $ALPINE_LATEST\n"
# Update Alpine version
sed -i "s/alpine:${ALPINE_CURRENT}/alpine:${ALPINE_LATEST}/g" dockerhub/alpine/Dockerfile
fi
# Check distroless Debian version
DISTROLESS_CURRENT=$(grep -oP 'base-nossl-debian\K[0-9]+' dockerhub/distroless/Dockerfile | head -1)
# Debian 12 is bookworm, check if we should update
if [ "$DISTROLESS_CURRENT" -lt "12" ]; then
echo "Distroless needs update from debian$DISTROLESS_CURRENT to debian12"
UPDATES_NEEDED=true
UPDATE_MESSAGE="${UPDATE_MESSAGE}Distroless: debian$DISTROLESS_CURRENT -> debian12\n"
sed -i "s/base-nossl-debian${DISTROLESS_CURRENT}/base-nossl-debian12/g" dockerhub/distroless/Dockerfile
fi
echo "updates_needed=$UPDATES_NEEDED" >> $GITHUB_OUTPUT
echo "update_message<<EOF" >> $GITHUB_OUTPUT
echo -e "$UPDATE_MESSAGE" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Run vulnerability scan on current images
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
run: |
# Install trivy
sudo apt-get update
sudo apt-get install -y wget apt-transport-https gnupg lsb-release
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo apt-key add -
echo "deb https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" | sudo tee -a /etc/apt/sources.list.d/trivy.list
sudo apt-get update
sudo apt-get install -y trivy
# Scan each Dockerfile
for dockerfile in dockerhub/*/Dockerfile; do
dir=$(dirname "$dockerfile")
variant=$(basename "$dir")
echo "Scanning $variant..."
# Build the image locally
docker build -t "bun-test:$variant" "$dir" --build-arg BUN_VERSION=latest || true
# Run trivy scan
trivy image --severity HIGH,CRITICAL "bun-test:$variant" || true
done
- name: Create Pull Request
if: steps.check-updates.outputs.updates_needed == 'true' && github.event_name == 'schedule'
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "chore: update Docker base images for security"
title: "chore: update Docker base images for security"
body: |
## Automated Docker Base Image Updates
This PR updates the base images used in our Docker containers to their latest versions for security patches.
### Updates:
${{ steps.check-updates.outputs.update_message }}
### Security Benefits:
- Patches latest CVEs in base OS packages
- Updates system libraries to latest stable versions
- Ensures compliance with security best practices
Please review and merge to keep our Docker images secure.
branch: docker-base-updates
delete-branch: true
scan-published-images:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'oven-sh' }}
strategy:
matrix:
variant: [latest, slim, alpine, distroless, debian]
steps:
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: 'oven/bun:${{ matrix.variant }}'
format: 'sarif'
output: 'trivy-results-${{ matrix.variant }}.sarif'
severity: 'HIGH,CRITICAL'
- name: Upload Trivy results to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: 'trivy-results-${{ matrix.variant }}.sarif'
category: 'trivy-${{ matrix.variant }}'

View File

@@ -1,194 +0,0 @@
# Docker Image Improvements
This document outlines the comprehensive improvements made to Bun's Docker images to address security vulnerabilities, missing features, and outdated base images.
## Summary of Changes
### 1. Security Updates
#### Base Image Updates
- **Distroless**: Updated from `debian11` to `debian12` (bookworm) - Addresses CVE vulnerabilities reported in #22594
- **Alpine**: Updated from `3.20` to `3.21` - Latest stable Alpine release with security patches
- **Debian/Debian-slim**: Ensured using `bookworm` (Debian 12) consistently
#### Automated Security Workflow
- Added `.github/workflows/docker-security.yml` for:
- Weekly automated base image updates
- Vulnerability scanning with Trivy
- Automatic PR creation for security updates
- SARIF upload to GitHub Security tab
### 2. Feature Additions
#### Essential Packages
Added commonly requested packages to all non-distroless images:
- **git**: Addresses #4687 - Required for git-based npm dependencies
- **ca-certificates**: Ensures HTTPS connections work properly
- **python3** (debian only): For node-gyp compatibility
### 3. Build and Publishing Improvements
#### New Scripts
- **`test-all-images.sh`**: Comprehensive testing script that:
- Builds all Docker variants
- Tests bun/bunx commands
- Verifies JavaScript execution
- Checks package installation
- Runs security scans
- **`publish-images.sh`**: Production-ready publishing script that:
- Supports multi-architecture builds (amd64/arm64)
- Handles semantic versioning tags
- Provides dry-run capability
- Manages all variant suffixes correctly
### 4. Issues Addressed
The following issues are resolved or improved by these changes:
- **#22594**: HIGH vulnerabilities in Debian images - Fixed by updating base images
- **#20414**: Distroless images outdated - Fixed by ensuring distroless is built in CI
- **#4687**: Missing git in Docker images - Fixed by adding git to all images
- **#3272**: CVEs in Docker images - Addressed via base image updates and security scanning
- **#17463**: Shell commands issues in distroless - Documented as expected behavior
- **#18325**: bunx not available in Alpine - Verified symlink creation
## Docker Image Variants
### 1. Debian (Full)
- Base: `debian:bookworm`
- Includes: git, curl, python3, ca-certificates
- Use case: Full compatibility, development environments
### 2. Debian Slim
- Base: `debian:bookworm-slim`
- Includes: git, ca-certificates
- Use case: Production with minimal overhead
### 3. Alpine
- Base: `alpine:3.21`
- Includes: git, ca-certificates, libgcc, libstdc++
- Use case: Smallest image size with package management
### 4. Distroless
- Base: `gcr.io/distroless/base-nossl-debian12`
- Includes: Only Bun binary
- Use case: Maximum security, minimal attack surface
- Note: No shell, no package manager - purely for running Bun applications
## Usage Examples
### Basic Usage
```dockerfile
# Development - full features
FROM oven/bun:latest
# Production - smaller size
FROM oven/bun:slim
# Minimal - smallest size with package manager
FROM oven/bun:alpine
# Maximum security - no shell
FROM oven/bun:distroless
```
### With Git Dependencies
```dockerfile
FROM oven/bun:slim
WORKDIR /app
COPY package.json bun.lockb ./
# Git is now available for git-based dependencies
RUN bun install --frozen-lockfile
COPY . .
CMD ["bun", "run", "start"]
```
### Multi-stage Build
```dockerfile
# Build stage
FROM oven/bun:latest AS build
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
# Production stage - distroless for security
FROM oven/bun:distroless
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
ENTRYPOINT ["bun", "dist/server.js"]
```
## CI/CD Integration
### GitHub Actions
The release workflow now:
1. Builds all variants including distroless
2. Pushes to Docker Hub with proper tags
3. Runs security scans
4. Updates base images automatically
### Local Testing
```bash
# Test all images
cd dockerhub
./test-all-images.sh
# Test with specific version
./test-all-images.sh v1.2.22
# Build and publish (dry run)
./publish-images.sh
# Build and publish (actual push)
PUSH_IMAGES=true BUN_VERSION=v1.2.22 ./publish-images.sh
```
## Security Best Practices
1. **Use distroless for production** when possible - smallest attack surface
2. **Regular updates** - Automated weekly base image updates via GitHub Actions
3. **Vulnerability scanning** - Integrated Trivy scanning in CI
4. **Minimal packages** - Only essential packages included
5. **Non-root user** - All images run as `bun` user (UID 1000)
## Migration Guide
### From Old Images
If you're using older Bun Docker images:
1. **Update base image tags** in your Dockerfiles
2. **Remove git installation** steps - git is now included
3. **Check for CVE warnings** - New images address known vulnerabilities
4. **Test distroless** - Consider migrating to distroless for production
### Breaking Changes
- Distroless now uses Debian 12 instead of Debian 11
- Alpine updated to 3.21 (check for Alpine-specific compatibility)
## Future Improvements
Potential future enhancements:
- [ ] Add HEALTHCHECK instructions
- [ ] Create debug variants with additional tools
- [ ] Add Windows container support
- [ ] Implement image signing with cosign
- [ ] Add SBOM (Software Bill of Materials) generation
## Contributing
To contribute to Docker image improvements:
1. Test changes with `./test-all-images.sh`
2. Update this documentation
3. Ensure CI passes
4. Submit PR with clear description of changes
## Support
For Docker-related issues:
- File issues with the `docker` label
- Include Docker variant and version
- Provide minimal reproduction Dockerfile

View File

@@ -1,4 +1,4 @@
FROM alpine:3.21 AS build
FROM alpine:3.20 AS build
# https://github.com/oven-sh/bun/releases
ARG BUN_VERSION=latest
@@ -44,7 +44,7 @@ RUN apk --no-cache add ca-certificates curl dirmngr gpg gpg-agent unzip \
&& rm -f "bun-linux-$build.zip" SHASUMS256.txt.asc SHASUMS256.txt \
&& chmod +x /usr/local/bin/bun
FROM alpine:3.21
FROM alpine:3.20
# Disable the runtime transpiler cache by default inside Docker containers.
# On ephemeral containers, the cache is not useful
@@ -65,7 +65,7 @@ RUN --mount=type=bind,from=build,source=/tmp,target=/tmp \
addgroup -g 1000 bun \
&& adduser -u 1000 -G bun -s /bin/sh -D bun \
&& ln -s /usr/local/bin/bun /usr/local/bin/bunx \
&& apk add --no-cache libgcc libstdc++ git ca-certificates \
&& apk add libgcc libstdc++ \
&& which bun \
&& which bunx \
&& bun --version

View File

@@ -57,14 +57,6 @@ RUN apt-get update -qq \
FROM debian:bookworm-slim
# Install essential packages including git
RUN apt-get update -qq \
&& apt-get install -qq --no-install-recommends \
ca-certificates \
git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Disable the runtime transpiler cache by default inside Docker containers.
# On ephemeral containers, the cache is not useful
ARG BUN_RUNTIME_TRANSPILER_CACHE_PATH=0

View File

@@ -58,16 +58,6 @@ RUN apt-get update -qq \
FROM debian:bookworm
# Install commonly needed packages
RUN apt-get update -qq
&& apt-get install -qq --no-install-recommends
ca-certificates
git
curl
python3
&& apt-get clean
&& rm -rf /var/lib/apt/lists/*
COPY docker-entrypoint.sh /usr/local/bin
COPY --from=build /usr/local/bin/bun /usr/local/bin/bun
RUN mkdir -p /usr/local/bun-node-fallback-bin && ln -s /usr/local/bin/bun /usr/local/bun-node-fallback-bin/node

View File

@@ -55,7 +55,7 @@ RUN apt-get update -qq \
&& which bun \
&& bun --version
FROM gcr.io/distroless/base-nossl-debian12
FROM gcr.io/distroless/base-nossl-debian11
# Disable the runtime transpiler cache by default inside Docker containers.
# On ephemeral containers, the cache is not useful

View File

@@ -1,211 +0,0 @@
#!/bin/bash
set -e
# This script publishes Bun Docker images to Docker Hub
# It should be run from CI/CD or manually with proper credentials
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
DOCKER_REPO="oven/bun"
BUN_VERSION=${BUN_VERSION:-latest}
PUSH_IMAGES=${PUSH_IMAGES:-false}
PLATFORMS="linux/amd64,linux/arm64"
echo -e "${BLUE}Bun Docker Image Publisher${NC}"
echo "================================"
echo "Version: $BUN_VERSION"
echo "Repository: $DOCKER_REPO"
echo "Push enabled: $PUSH_IMAGES"
echo "Platforms: $PLATFORMS"
echo ""
# Check if docker buildx is available
if ! docker buildx version &> /dev/null; then
echo -e "${RED}Error: Docker buildx is required but not found${NC}"
echo "Please install Docker buildx: https://docs.docker.com/buildx/working-with-buildx/"
exit 1
fi
# Setup buildx builder
BUILDER_NAME="bun-multiarch-builder"
if ! docker buildx ls | grep -q "$BUILDER_NAME"; then
echo "Creating buildx builder..."
docker buildx create --name "$BUILDER_NAME" --platform "$PLATFORMS" --use
else
echo "Using existing buildx builder..."
docker buildx use "$BUILDER_NAME"
fi
# Ensure builder is running
docker buildx inspect --bootstrap
# Function to build and optionally push an image
build_and_push() {
local variant=$1
local dir=$2
local tags=$3
echo -e "\n${YELLOW}Building $variant...${NC}"
echo "Directory: $dir"
echo "Tags: $tags"
# Prepare build arguments
local build_args="--platform $PLATFORMS"
build_args="$build_args --build-arg BUN_VERSION=$BUN_VERSION"
# Add tags
for tag in $tags; do
build_args="$build_args --tag $DOCKER_REPO:$tag"
done
# Add push flag if enabled
if [ "$PUSH_IMAGES" = "true" ]; then
build_args="$build_args --push"
else
build_args="$build_args --load"
fi
# Build the image
echo "Running: docker buildx build $build_args $dir"
if docker buildx build $build_args "$dir"; then
echo -e "${GREEN}✓ Successfully built $variant${NC}"
return 0
else
echo -e "${RED}✗ Failed to build $variant${NC}"
return 1
fi
}
# Determine version tags
determine_tags() {
local variant=$1
local suffix=$2
local tags=""
case "$BUN_VERSION" in
latest)
# For latest, we tag with 'latest' and the variant name
if [ -z "$suffix" ]; then
tags="latest"
else
tags="latest$suffix"
fi
# Also tag with just the variant name for convenience
tags="$tags $variant"
;;
canary)
tags="canary$suffix"
if [ "$variant" != "debian" ]; then
tags="$tags canary-$variant"
fi
;;
v*.*.*)
# Semantic version
local version=${BUN_VERSION#v}
local major=$(echo "$version" | cut -d. -f1)
local minor=$(echo "$version" | cut -d. -f2)
# Add full version tag
tags="$version$suffix"
# Add major.minor tag
tags="$tags $major.$minor$suffix"
# Add major tag
tags="$tags $major$suffix"
# For non-debian variants, also add variant-specific tags
if [ "$variant" != "debian" ]; then
tags="$tags $version-$variant"
fi
;;
*)
# Custom tag
tags="${BUN_VERSION}$suffix"
;;
esac
echo "$tags"
}
# Build configurations
declare -A VARIANTS=(
["debian"]=""
["debian-slim"]="-slim"
["alpine"]="-alpine"
["distroless"]="-distroless"
)
# Track results
FAILED_BUILDS=()
SUCCESSFUL_BUILDS=()
# Build each variant
for variant in "${!VARIANTS[@]}"; do
suffix="${VARIANTS[$variant]}"
# Determine directory
if [ "$variant" = "debian-slim" ]; then
dir="./debian-slim"
elif [ "$variant" = "debian" ]; then
dir="./debian"
else
dir="./$variant"
fi
# Check if directory exists
if [ ! -d "$dir" ]; then
echo -e "${RED}✗ Directory $dir not found for $variant${NC}"
FAILED_BUILDS+=("$variant")
continue
fi
# Determine tags for this variant
tags=$(determine_tags "$variant" "$suffix")
# Build and optionally push
if build_and_push "$variant" "$dir" "$tags"; then
SUCCESSFUL_BUILDS+=("$variant")
else
FAILED_BUILDS+=("$variant")
fi
done
# Print summary
echo -e "\n${YELLOW}========== BUILD SUMMARY ==========${NC}"
echo -e "${GREEN}Successful: ${#SUCCESSFUL_BUILDS[@]} variants${NC}"
for variant in "${SUCCESSFUL_BUILDS[@]}"; do
echo -e " ${GREEN}${NC} $variant"
done
if [ ${#FAILED_BUILDS[@]} -gt 0 ]; then
echo -e "${RED}Failed: ${#FAILED_BUILDS[@]} variants${NC}"
for variant in "${FAILED_BUILDS[@]}"; do
echo -e " ${RED}${NC} $variant"
done
exit 1
fi
# If pushing was enabled, show the published tags
if [ "$PUSH_IMAGES" = "true" ]; then
echo -e "\n${GREEN}Images successfully published to $DOCKER_REPO${NC}"
echo "You can pull them with:"
for variant in "${!VARIANTS[@]}"; do
suffix="${VARIANTS[$variant]}"
tags=$(determine_tags "$variant" "$suffix")
for tag in $tags; do
echo " docker pull $DOCKER_REPO:$tag"
done
done
else
echo -e "\n${YELLOW}Images built locally (not pushed)${NC}"
echo "To push images, set PUSH_IMAGES=true"
fi
echo -e "\n${GREEN}Done!${NC}"

View File

@@ -1,138 +0,0 @@
#!/bin/bash
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo "Building and testing all Bun Docker images..."
# Get the latest Bun version or use a specified one
BUN_VERSION=${1:-latest}
echo "Using Bun version: $BUN_VERSION"
# Array of variants to test
VARIANTS=("debian" "debian-slim" "alpine" "distroless")
# Track results
FAILED_VARIANTS=()
PASSED_VARIANTS=()
# Function to test a Docker image
test_docker_image() {
local variant=$1
local dir=$2
local tag="bun-test:$variant"
echo -e "\n${YELLOW}Testing $variant...${NC}"
# Build the image
echo "Building $variant image..."
if ! docker build -t "$tag" "$dir" --build-arg BUN_VERSION="$BUN_VERSION" 2>&1 | tail -20; then
echo -e "${RED}✗ Failed to build $variant${NC}"
FAILED_VARIANTS+=("$variant (build failed)")
return 1
fi
# Test 1: Check if bun is installed and works
echo "Testing bun command..."
if ! docker run --rm "$tag" bun --version; then
echo -e "${RED}✗ bun --version failed for $variant${NC}"
FAILED_VARIANTS+=("$variant (bun command failed)")
return 1
fi
# Test 2: Check if bunx works
echo "Testing bunx command..."
if ! docker run --rm "$tag" sh -c 'which bunx && bunx --version' 2>/dev/null; then
echo -e "${YELLOW}⚠ bunx not available in $variant (may be expected for distroless)${NC}"
fi
# Test 3: Test a simple JavaScript execution
echo "Testing JavaScript execution..."
if ! docker run --rm "$tag" bun eval 'console.log("Hello from Bun!")'; then
echo -e "${RED}✗ JavaScript execution failed for $variant${NC}"
FAILED_VARIANTS+=("$variant (JS execution failed)")
return 1
fi
# Test 4: Check if git is installed (except distroless)
if [ "$variant" != "distroless" ]; then
echo "Testing git availability..."
if ! docker run --rm "$tag" sh -c 'which git' 2>/dev/null; then
echo -e "${YELLOW}⚠ git not available in $variant${NC}"
fi
fi
# Test 5: Test package installation
echo "Testing package installation..."
if ! docker run --rm "$tag" sh -c 'echo "{\"name\":\"test\",\"dependencies\":{\"is-number\":\"*\"}}" > package.json && bun install --no-save 2>&1 | grep -q "is-number"' 2>/dev/null; then
if [ "$variant" = "distroless" ]; then
echo -e "${YELLOW}⚠ Package installation test skipped for distroless (no shell)${NC}"
else
echo -e "${YELLOW}⚠ Package installation may have issues in $variant${NC}"
fi
fi
# Test 6: Check multi-arch support
echo "Checking image architecture..."
docker run --rm "$tag" sh -c 'uname -m' 2>/dev/null || echo "Architecture check skipped (no shell)"
# Test 7: Security scan with trivy (if available)
if command -v trivy &> /dev/null; then
echo "Running security scan..."
trivy image --severity HIGH,CRITICAL --no-progress "$tag" 2>/dev/null | grep -E "Total:|HIGH:|CRITICAL:" || echo "No HIGH/CRITICAL vulnerabilities found"
fi
echo -e "${GREEN}$variant tests passed${NC}"
PASSED_VARIANTS+=("$variant")
return 0
}
# Test each variant
for variant in "${VARIANTS[@]}"; do
# Determine the directory
if [ "$variant" = "debian-slim" ]; then
dir="./debian-slim"
elif [ "$variant" = "debian" ]; then
dir="./debian"
else
dir="./$variant"
fi
# Check if directory exists
if [ ! -d "$dir" ]; then
echo -e "${RED}✗ Directory $dir not found for $variant${NC}"
FAILED_VARIANTS+=("$variant (directory not found)")
continue
fi
test_docker_image "$variant" "$dir" || true
done
# Print summary
echo -e "\n${YELLOW}========== TEST SUMMARY ==========${NC}"
echo -e "${GREEN}Passed: ${#PASSED_VARIANTS[@]} variants${NC}"
for variant in "${PASSED_VARIANTS[@]}"; do
echo -e " ${GREEN}${NC} $variant"
done
if [ ${#FAILED_VARIANTS[@]} -gt 0 ]; then
echo -e "${RED}Failed: ${#FAILED_VARIANTS[@]} variants${NC}"
for variant in "${FAILED_VARIANTS[@]}"; do
echo -e " ${RED}${NC} $variant"
done
exit 1
else
echo -e "\n${GREEN}All Docker images built and tested successfully!${NC}"
fi
# Cleanup test images
echo -e "\nCleaning up test images..."
for variant in "${VARIANTS[@]}"; do
docker rmi "bun-test:$variant" 2>/dev/null || true
done
echo "Done!"

View File

@@ -1,3 +1,4 @@
#include "root.h"
#include "JSBundlerPlugin.h"
#include "BunProcess.h"
@@ -57,6 +58,7 @@ JSC_DECLARE_HOST_FUNCTION(jsBundlerPluginFunction_onLoadAsync);
JSC_DECLARE_HOST_FUNCTION(jsBundlerPluginFunction_onResolveAsync);
JSC_DECLARE_HOST_FUNCTION(jsBundlerPluginFunction_onBeforeParse);
JSC_DECLARE_HOST_FUNCTION(jsBundlerPluginFunction_generateDeferPromise);
JSC_DECLARE_HOST_FUNCTION(jsBundlerPluginFunction_addVirtualModule);
void BundlerPlugin::NamespaceList::append(JSC::VM& vm, JSC::RegExp* filter, String& namespaceString, unsigned& index)
{
@@ -101,6 +103,17 @@ static bool anyMatchesForNamespace(JSC::VM& vm, BundlerPlugin::NamespaceList& li
}
bool BundlerPlugin::anyMatchesCrossThread(JSC::VM& vm, const BunString* namespaceStr, const BunString* path, bool isOnLoad)
{
auto pathString = path->toWTFString(BunString::ZeroCopy);
// Check virtual modules for both onLoad and onResolve (only in "file" namespace)
if (!this->virtualModules.isEmpty()) {
// Virtual modules only work in the "file" namespace or empty namespace (defaults to "file")
bool isFileNamespace = !namespaceStr || namespaceStr->isEmpty() || namespaceStr->toWTFString(BunString::ZeroCopy) == "file"_s;
if (isFileNamespace && this->virtualModules.contains(pathString)) {
return true;
}
}
if (isOnLoad) {
return anyMatchesForNamespace(vm, this->onLoad, namespaceStr, path);
} else {
@@ -108,6 +121,43 @@ bool BundlerPlugin::anyMatchesCrossThread(JSC::VM& vm, const BunString* namespac
}
}
bool BundlerPlugin::hasVirtualModule(const String& path) const
{
return !virtualModules.isEmpty() && virtualModules.contains(path);
}
JSC::JSObject* BundlerPlugin::getVirtualModule(const String& path)
{
if (virtualModules.isEmpty()) {
return nullptr;
}
auto it = virtualModules.find(path);
if (it != virtualModules.end()) {
unsigned index = it->value;
if (index < virtualModulesList.list().size()) {
return virtualModulesList.list()[index].get();
}
}
return nullptr;
}
void BundlerPlugin::addVirtualModule(JSC::VM& vm, JSC::JSCell* owner, const String& path, JSC::JSObject* moduleFunction)
{
unsigned index = virtualModulesList.list().size();
virtualModulesList.append(vm, owner, moduleFunction);
virtualModules.set(path, index);
}
void BundlerPlugin::tombstone()
{
tombstoned = true;
virtualModules.clear();
// virtualModulesList will be cleaned up by destructor
}
// Template implementation moved to header file
static const HashTableValue JSBundlerPluginHashTable[] = {
{ "addFilter"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, jsBundlerPluginFunction_addFilter, 3 } },
{ "addError"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, jsBundlerPluginFunction_addError, 3 } },
@@ -115,6 +165,7 @@ static const HashTableValue JSBundlerPluginHashTable[] = {
{ "onResolveAsync"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, jsBundlerPluginFunction_onResolveAsync, 4 } },
{ "onBeforeParse"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, jsBundlerPluginFunction_onBeforeParse, 4 } },
{ "generateDeferPromise"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, jsBundlerPluginFunction_generateDeferPromise, 0 } },
{ "addVirtualModule"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, jsBundlerPluginFunction_addVirtualModule, 2 } },
};
class JSBundlerPlugin final : public JSC::JSDestructibleObject {
@@ -193,7 +244,7 @@ void JSBundlerPlugin::visitAdditionalChildren(Visitor& visitor)
this->onLoadFunction.visit(visitor);
this->onResolveFunction.visit(visitor);
this->setupFunction.visit(visitor);
this->plugin.deferredPromises.visit(this, visitor);
this->plugin.visitAdditionalChildren(this, visitor);
}
template<typename Visitor>
@@ -467,6 +518,37 @@ JSC_DEFINE_HOST_FUNCTION(jsBundlerPluginFunction_generateDeferPromise, (JSC::JSG
return encoded_defer_promise;
}
JSC_DEFINE_HOST_FUNCTION(jsBundlerPluginFunction_addVirtualModule, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))
{
JSBundlerPlugin* thisObject = jsCast<JSBundlerPlugin*>(callFrame->thisValue());
if (thisObject->plugin.tombstoned) {
return JSC::JSValue::encode(JSC::jsUndefined());
}
auto& vm = globalObject->vm();
auto scope = DECLARE_THROW_SCOPE(vm);
JSC::JSValue pathValue = callFrame->argument(0);
JSC::JSValue moduleValue = callFrame->argument(1);
if (!pathValue.isString()) {
throwTypeError(globalObject, scope, "Expected first argument to be a string"_s);
return JSC::JSValue::encode({});
}
if (!moduleValue.isObject() || !moduleValue.isCallable()) {
throwTypeError(globalObject, scope, "Expected second argument to be a function"_s);
return JSC::JSValue::encode({});
}
WTF::String path = pathValue.toWTFString(globalObject);
RETURN_IF_EXCEPTION(scope, {});
thisObject->plugin.addVirtualModule(vm, thisObject, path, JSC::asObject(moduleValue));
return JSC::JSValue::encode(JSC::jsUndefined());
}
void JSBundlerPlugin::finishCreation(JSC::VM& vm)
{
Base::finishCreation(vm);
@@ -738,4 +820,29 @@ extern "C" JSC::JSGlobalObject* JSBundlerPlugin__globalObject(Bun::JSBundlerPlug
return plugin->m_globalObject;
}
extern "C" bool JSBundlerPlugin__hasVirtualModule(Bun::JSBundlerPlugin* plugin, const BunString* path)
{
WTF::String pathStr = path ? path->toWTFString(BunString::ZeroCopy) : WTF::String();
return plugin->plugin.hasVirtualModule(pathStr);
}
extern "C" JSC::EncodedJSValue JSBundlerPlugin__getVirtualModule(Bun::JSBundlerPlugin* plugin, const BunString* path)
{
WTF::String pathStr = path ? path->toWTFString(BunString::ZeroCopy) : WTF::String();
auto* virtualModule = plugin->plugin.getVirtualModule(pathStr);
if (virtualModule) {
return JSC::JSValue::encode(virtualModule);
}
return JSC::JSValue::encode(JSC::jsUndefined());
}
extern "C" void JSBundlerPlugin__addVirtualModule(Bun::JSBundlerPlugin* plugin, const BunString* path, JSC::EncodedJSValue encodedModuleFunction)
{
WTF::String pathStr = path ? path->toWTFString(BunString::ZeroCopy) : WTF::String();
JSC::JSValue moduleFunction = JSC::JSValue::decode(encodedModuleFunction);
if (moduleFunction.isObject()) {
plugin->plugin.addVirtualModule(plugin->vm(), plugin, pathStr, JSC::asObject(moduleFunction));
}
}
} // namespace Bun

View File

@@ -20,6 +20,8 @@ using namespace JSC;
class BundlerPlugin final {
public:
using VirtualModuleMap = WTF::UncheckedKeyHashMap<String, unsigned>;
/// In native plugins, the regular expression could be called concurrently on multiple threads.
/// Therefore, we need a mutex to synchronize access.
class FilterRegExp {
@@ -119,7 +121,16 @@ public:
public:
bool anyMatchesCrossThread(JSC::VM&, const BunString* namespaceStr, const BunString* path, bool isOnLoad);
void tombstone() { tombstoned = true; }
bool hasVirtualModule(const String& path) const;
JSC::JSObject* getVirtualModule(const String& path);
void addVirtualModule(JSC::VM& vm, JSC::JSCell* owner, const String& path, JSC::JSObject* moduleFunction);
void tombstone();
template<typename Visitor>
void visitAdditionalChildren(JSC::JSCell* cell, Visitor& visitor)
{
deferredPromises.visit(cell, visitor);
virtualModulesList.visit(cell, visitor);
}
BundlerPlugin(void* config, BunPluginTarget target, JSBundlerPluginAddErrorCallback addError, JSBundlerPluginOnLoadAsyncCallback onLoadAsync, JSBundlerPluginOnResolveAsyncCallback onResolveAsync)
: addError(addError)
@@ -130,12 +141,18 @@ public:
this->config = config;
}
~BundlerPlugin()
{
}
NamespaceList onLoad = {};
NamespaceList onResolve = {};
NativePluginList onBeforeParse = {};
BunPluginTarget target { BunPluginTargetBrowser };
WriteBarrierList<JSC::JSPromise> deferredPromises = {};
VirtualModuleMap virtualModules {};
WriteBarrierList<JSC::JSObject> virtualModulesList = {};
JSBundlerPluginAddErrorCallback addError;
JSBundlerPluginOnLoadAsyncCallback onLoadAsync;
@@ -144,4 +161,4 @@ public:
bool tombstoned { false };
};
} // namespace Zig
} // namespace Bun

View File

@@ -2894,6 +2894,7 @@ pub const BundleV2 = struct {
original_target: options.Target,
) bool {
if (this.plugins) |plugins| {
// anyMatches will check for virtual modules too
if (plugins.hasAnyMatches(&import_record.path, false)) {
// This is where onResolve plugins are enqueued
var resolve: *jsc.API.JSBundler.Resolve = bun.default_allocator.create(jsc.API.JSBundler.Resolve) catch unreachable;

View File

@@ -8,6 +8,7 @@ interface BundlerPlugin {
onLoad: Map<string, [RegExp, OnLoadCallback][]>;
onResolve: Map<string, [RegExp, OnResolveCallback][]>;
onEndCallbacks: Array<(build: Bun.BuildOutput) => void | Promise<void>> | undefined;
virtualModules: Map<string, () => { contents: string; loader?: string }> | undefined;
/** Binding to `JSBundlerPlugin__onLoadAsync` */
onLoadAsync(
internalID,
@@ -19,6 +20,7 @@ interface BundlerPlugin {
/** Binding to `JSBundlerPlugin__addError` */
addError(internalID: number, error: any, which: number): void;
addFilter(filter, namespace, number): void;
addVirtualModule(path: string, moduleFunction: () => any): void;
generateDeferPromise(id: number): Promise<void>;
promises: Array<Promise<any>> | undefined;
@@ -112,6 +114,12 @@ export function runOnEndCallbacks(
buildResult: Bun.BuildOutput,
buildRejection: AggregateError | undefined,
): Promise<void> | void {
// Clean up virtual modules when build ends
if (this.virtualModules) {
this.virtualModules.clear();
this.virtualModules = undefined;
}
const callbacks = this.onEndCallbacks;
if (!callbacks) return;
const promises: PromiseLike<unknown>[] = [];
@@ -347,8 +355,33 @@ export function runSetupFunction(
onBeforeParse,
onStart,
resolve: notImplementedIssueFn(2771, "build.resolve()"),
module: () => {
throw new TypeError("module() is not supported in Bun.build() yet. Only via Bun.plugin() at runtime");
module: (specifier: string, callback: () => { contents: string; loader?: string }) => {
if (!(typeof specifier === "string")) {
throw $ERR_INVALID_ARG_TYPE("specifier", "string", specifier);
}
if (!$isCallable(callback)) {
throw $ERR_INVALID_ARG_TYPE("callback", "function", callback);
}
// Store the virtual module
if (!self.virtualModules) {
self.virtualModules = new Map();
}
// Check for duplicate registration
if (self.virtualModules.has(specifier)) {
const prev = self.virtualModules.get(specifier);
if (prev !== callback) {
throw new TypeError(`Virtual module "${specifier}" is already registered`);
}
return this; // idempotent - same callback already registered
}
// Register with native first; update JS map only on success
self.addVirtualModule(specifier, callback);
self.virtualModules.set(specifier, callback);
return this;
},
addPreload: () => {
throw new TypeError("addPreload() is not supported in Bun.build() yet.");
@@ -395,8 +428,21 @@ export function runOnResolvePlugins(this: BundlerPlugin, specifier, inputNamespa
const kind = $ImportKindIdToLabel[kindId];
var promiseResult: any = (async (inputPath, inputNamespace, importer, kind) => {
var { onResolve, onLoad } = this;
var { onResolve, onLoad, virtualModules } = this;
// Check for virtual modules first (in file namespace or empty namespace)
if (virtualModules && (!inputNamespace || inputNamespace === "file") && virtualModules.has(inputPath)) {
this.onResolveAsync(internalID, inputPath, "file", false);
return null;
}
if (!onResolve) {
this.onResolveAsync(internalID, null, null, null);
return null;
}
var results = onResolve.$get(inputNamespace);
if (!results) {
this.onResolveAsync(internalID, null, null, null);
return null;
@@ -511,6 +557,74 @@ export function runOnLoadPlugins(
const generateDefer = () => this.generateDeferPromise(internalID);
var promiseResult = (async (internalID, path, namespace, isServerSide, defaultLoader, generateDefer) => {
// Check for virtual modules first (file namespace and in virtualModules map)
if (this.virtualModules && this.virtualModules.has(path) && namespace === "file") {
const virtualModuleCallback = this.virtualModules.get(path);
if (virtualModuleCallback) {
let result;
try {
result = virtualModuleCallback();
} catch (e) {
// If the callback throws, report it as an error
this.addError(internalID, e, 1);
return null;
}
try {
// Unwrap/await promises like onLoad
while (
result &&
$isPromise(result) &&
($getPromiseInternalField(result, $promiseFieldFlags) & $promiseStateMask) === $promiseStateFulfilled
) {
result = $getPromiseInternalField(result, $promiseFieldReactionsOrResult);
}
if (result && $isPromise(result)) {
result = await result;
}
if (!result || !$isObject(result)) {
throw new TypeError(`Virtual module "${path}" must return an object with "contents" property`);
}
var { contents, loader = "js" } = result as any;
if ((loader as any) === "object") {
if (!("exports" in result)) {
throw new TypeError('Virtual module returning loader: "object" must have "exports" property');
}
try {
contents = JSON.stringify(result.exports);
loader = "json";
} catch (e) {
throw new TypeError(
'Virtual module must return a JSON-serializable object when using loader: "object": ' + e,
);
}
}
if (!(typeof contents === "string") && !$isTypedArrayView(contents)) {
throw new TypeError(
`Virtual module "${path}" must return an object with "contents" as a string or Uint8Array`,
);
}
if (!(typeof loader === "string")) {
throw new TypeError(`Virtual module "${path}" "loader" must be a string if provided`);
}
const chosenLoader = LOADERS_MAP[loader];
if (chosenLoader === undefined) {
throw new TypeError(`Virtual module "${path}": Loader ${loader} is not supported.`);
}
this.onLoadAsync(internalID, contents as any, chosenLoader);
return null;
} catch (e) {
this.addError(internalID, e, 1);
return null;
}
}
}
var results = this.onLoad.$get(namespace);
if (!results) {
this.onLoadAsync(internalID, null, null);

View File

@@ -402,27 +402,41 @@ describe("Bun.build", () => {
expect(x.logs[0].position).toBeTruthy();
});
test("module() throws error", async () => {
expect(() =>
Bun.build({
entrypoints: [join(import.meta.dir, "./fixtures/trivial/bundle-ws.ts")],
plugins: [
{
name: "test",
setup: b => {
b.module("ad", () => {
return {
exports: {
hello: "world",
},
loader: "object",
};
});
},
test("module() is now supported", async () => {
const dir = tempDirWithFiles("module-api-test", {
"entry.js": `
import msg from "test-virtual-module";
console.log(msg);
`,
});
// module() is now implemented and should not throw
const result = await Bun.build({
entrypoints: [join(String(dir), "entry.js")],
outdir: String(dir),
plugins: [
{
name: "test",
setup: b => {
// Verify module() exists and can be called
expect(typeof b.module).toBe("function");
b.module("test-virtual-module", () => {
return {
contents: "export default 'Hello from virtual module';",
loader: "js",
};
});
},
],
}),
).toThrow();
},
],
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
// Check that the virtual module content is in the output
const output = await result.outputs[0].text();
expect(output).toContain("Hello from virtual module");
});
test("non-object plugins throw invalid argument errors", () => {

View File

@@ -0,0 +1,527 @@
import { expect, test } from "bun:test";
import { tempDir } from "harness";
import * as path from "node:path";
test("Bun.build plugin virtual modules - basic", async () => {
using dir = tempDir("virtual-basic", {
"entry.ts": `
import message from "my-virtual-module";
console.log(message);
`,
});
const result = await Bun.build({
entrypoints: [path.join(String(dir), "entry.ts")],
outdir: String(dir),
plugins: [
{
name: "virtual-module-plugin",
setup(build) {
build.module("my-virtual-module", () => ({
contents: `export default "Hello from virtual module!"`,
loader: "js",
}));
},
},
],
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("Hello from virtual module!");
});
test("Bun.build plugin virtual modules - multiple modules", async () => {
using dir = tempDir("virtual-multiple", {
"entry.ts": `
import { greeting } from "virtual-greeting";
import { name } from "virtual-name";
console.log(greeting + " " + name);
`,
});
const result = await Bun.build({
entrypoints: [path.join(String(dir), "entry.ts")],
outdir: String(dir),
plugins: [
{
name: "multi-virtual-plugin",
setup(build) {
build.module("virtual-greeting", () => ({
contents: `export const greeting = "Hello";`,
loader: "js",
}));
build.module("virtual-name", () => ({
contents: `export const name = "World";`,
loader: "js",
}));
},
},
],
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("Hello");
expect(output).toContain("World");
});
test("Bun.build plugin virtual modules - TypeScript", async () => {
using dir = tempDir("virtual-typescript", {
"entry.ts": `
import { calculate } from "virtual-math";
console.log(calculate(5, 10));
`,
});
const result = await Bun.build({
entrypoints: [path.join(String(dir), "entry.ts")],
outdir: String(dir),
plugins: [
{
name: "typescript-virtual-plugin",
setup(build) {
build.module("virtual-math", () => ({
contents: `
export function calculate(a: number, b: number): number {
return a + b;
}
`,
loader: "ts",
}));
},
},
],
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("calculate(5, 10)"); // Function call is in output
});
test("Bun.build plugin virtual modules - JSON", async () => {
using dir = tempDir("virtual-json", {
"entry.ts": `
import config from "virtual-config";
console.log(config.version);
`,
});
const result = await Bun.build({
entrypoints: [path.join(String(dir), "entry.ts")],
outdir: String(dir),
plugins: [
{
name: "json-virtual-plugin",
setup(build) {
build.module("virtual-config", () => ({
contents: JSON.stringify({ version: "1.2.3", enabled: true }),
loader: "json",
}));
},
},
],
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("1.2.3");
});
test("Bun.build plugin virtual modules - with onLoad and onResolve", async () => {
using dir = tempDir("virtual-mixed", {
"entry.ts": `
import virtual from "my-virtual";
import modified from "./real.js";
console.log(virtual, modified);
`,
"real.js": `export default "original";`,
});
const result = await Bun.build({
entrypoints: [path.join(String(dir), "entry.ts")],
outdir: String(dir),
plugins: [
{
name: "mixed-plugin",
setup(build) {
// Virtual module
build.module("my-virtual", () => ({
contents: `export default "virtual content";`,
loader: "js",
}));
// Regular onLoad plugin
build.onLoad({ filter: /\/real\.js$/ }, () => ({
contents: `export default "modified";`,
loader: "js",
}));
},
},
],
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("virtual content");
expect(output).toContain("modified");
});
test("Bun.build plugin virtual modules - dynamic content", async () => {
using dir = tempDir("virtual-dynamic", {
"entry.ts": `
import timestamp from "virtual-timestamp";
console.log(timestamp);
`,
});
const buildTime = Date.now();
const result = await Bun.build({
entrypoints: [path.join(String(dir), "entry.ts")],
outdir: String(dir),
plugins: [
{
name: "dynamic-virtual-plugin",
setup(build) {
build.module("virtual-timestamp", () => ({
contents: `export default ${buildTime};`,
loader: "js",
}));
},
},
],
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain(buildTime.toString());
});
test("Bun.build plugin virtual modules - nested imports", async () => {
using dir = tempDir("virtual-nested", {
"entry.ts": `
import { main } from "virtual-main";
console.log(main());
`,
});
const result = await Bun.build({
entrypoints: [path.join(String(dir), "entry.ts")],
outdir: String(dir),
plugins: [
{
name: "nested-virtual-plugin",
setup(build) {
build.module("virtual-main", () => ({
contents: `
import { helper } from "virtual-helper";
export function main() {
return helper() + " from main";
}
`,
loader: "js",
}));
build.module("virtual-helper", () => ({
contents: `
export function helper() {
return "Hello";
}
`,
loader: "js",
}));
},
},
],
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain('helper() + " from main"'); // Check for the function composition
});
test("Bun.build plugin virtual modules - multiple plugins", async () => {
using dir = tempDir("virtual-multi-plugin", {
"entry.ts": `
import first from "virtual-first";
import second from "virtual-second";
console.log(first, second);
`,
});
const result = await Bun.build({
entrypoints: [path.join(String(dir), "entry.ts")],
outdir: String(dir),
plugins: [
{
name: "first-plugin",
setup(build) {
build.module("virtual-first", () => ({
contents: `export default "from first plugin";`,
loader: "js",
}));
},
},
{
name: "second-plugin",
setup(build) {
build.module("virtual-second", () => ({
contents: `export default "from second plugin";`,
loader: "js",
}));
},
},
],
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await result.outputs[0].text();
expect(output).toContain("from first plugin");
expect(output).toContain("from second plugin");
});
test("Bun.build plugin virtual modules - error handling", async () => {
using dir = tempDir("virtual-error", {
"entry.ts": `
import data from "virtual-error";
console.log(data);
`,
});
// Plugin errors are thrown as "Bundle failed"
await expect(
Bun.build({
entrypoints: [path.join(String(dir), "entry.ts")],
outdir: String(dir),
plugins: [
{
name: "error-plugin",
setup(build) {
build.module("virtual-error", () => {
throw new Error("Failed to generate virtual module");
});
},
},
],
}),
).rejects.toThrow(/Bundle failed/i);
});
test("Bun.build plugin virtual modules - CSS", async () => {
using dir = tempDir("virtual-css", {
"entry.ts": `
import styles from "virtual-styles";
console.log(styles);
`,
});
const result = await Bun.build({
entrypoints: [path.join(String(dir), "entry.ts")],
outdir: String(dir),
plugins: [
{
name: "css-virtual-plugin",
setup(build) {
build.module("virtual-styles", () => ({
contents: `
.container {
display: flex;
justify-content: center;
align-items: center;
}
`,
loader: "css",
}));
},
},
],
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(2); // JS and CSS output
});
test("Bun.build plugin virtual modules - onLoad plugins still work", async () => {
using dir = tempDir("virtual-with-onload", {
"entry.ts": `
import virtual from "my-virtual";
import data from "./real.json";
console.log(virtual, data);
`,
"real.json": `{"original": "data"}`,
});
let onLoadCalled = false;
const result = await Bun.build({
entrypoints: [path.join(String(dir), "entry.ts")],
outdir: String(dir),
plugins: [
{
name: "combined-plugin",
setup(build) {
// Add virtual module
build.module("my-virtual", () => ({
contents: `export default "virtual content";`,
loader: "js",
}));
// Also add regular onLoad plugin for JSON files
build.onLoad({ filter: /\.json$/ }, args => {
onLoadCalled = true;
return {
contents: `{"modified": "by onLoad plugin"}`,
loader: "json",
};
});
},
},
],
});
expect(result.success).toBe(true);
expect(onLoadCalled).toBe(true);
const output = await result.outputs[0].text();
expect(output).toContain("virtual content");
expect(output).toContain("modified");
expect(output).toContain("by onLoad plugin");
});
test("Bun.build plugin virtual modules - duplicate registration throws", async () => {
using dir = tempDir("virtual-duplicate", {
"entry.js": `console.log("test");`,
});
await expect(
Bun.build({
entrypoints: [path.join(String(dir), "entry.js")],
outdir: String(dir),
plugins: [
{
name: "duplicate-test",
setup(build) {
// First registration
build.module("duplicate-module", () => ({
contents: `export default "first";`,
loader: "js",
}));
// Duplicate registration with different callback should throw
expect(() => {
build.module("duplicate-module", () => ({
contents: `export default "second";`,
loader: "js",
}));
}).toThrow('Virtual module "duplicate-module" is already registered');
// Same callback should be idempotent (not throw)
const sameCallback = () => ({
contents: `export default "first";`,
loader: "js",
});
build.module("another-module", sameCallback);
build.module("another-module", sameCallback); // Should not throw
},
},
],
}),
).resolves.toHaveProperty("success", true);
});
test("Bun.build plugin virtual modules - virtual module as entrypoint", async () => {
using dir = tempDir("virtual-entrypoint", {});
const result = await Bun.build({
entrypoints: ["virtual-entry"],
outdir: String(dir),
plugins: [
{
name: "in-memory-entrypoint",
setup(build) {
build.module("virtual-entry", () => ({
contents: `console.log("Hello from virtual entrypoint");`,
loader: "js",
}));
},
},
],
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
const output = await Bun.file(result.outputs[0].path).text();
expect(output).toContain("Hello from virtual entrypoint");
});
test("Bun.build plugin virtual modules - no memory leak on repeated builds", async () => {
using dir = tempDir("virtual-memory", {
"entry.ts": `
import msg from "test-module";
console.log(msg);
`,
});
// Track memory usage with multiple builds
const initialMemory = process.memoryUsage().heapUsed;
const memoryAfterBuilds = [];
// Run multiple builds to check for memory leaks
for (let i = 0; i < 10; i++) {
await Bun.build({
entrypoints: [path.join(String(dir), "entry.ts")],
outdir: String(dir),
plugins: [
{
name: `test-plugin-${i}`,
setup(build) {
// Create a large callback to make memory leaks more visible
const largeData = new Array(10000).fill(`data-${i}`);
build.module("test-module", () => ({
contents: `export default "${largeData[0]}";`,
loader: "js",
}));
},
},
],
});
// Force GC after each build if available
if (global.gc) {
global.gc();
}
memoryAfterBuilds.push(process.memoryUsage().heapUsed);
}
// Memory usage should stabilize and not continuously grow
// Check that the last few builds don't show significant growth
const lastThreeBuilds = memoryAfterBuilds.slice(-3);
const avgLastThree = lastThreeBuilds.reduce((a, b) => a + b, 0) / 3;
const firstThreeBuilds = memoryAfterBuilds.slice(0, 3);
const avgFirstThree = firstThreeBuilds.reduce((a, b) => a + b, 0) / 3;
// Memory shouldn't grow by more than 50% between first and last builds
// This is a loose check to avoid flakiness
const memoryGrowthRatio = avgLastThree / avgFirstThree;
expect(memoryGrowthRatio).toBeLessThan(1.5);
});