Blog

Bun v1.4.4

To install Bun

curl

curl -fsSL https://bun.sh/install | bash

To upgrade Bun

bun upgrade

bun fmt: a formatter built into Bun#

bun fmt is an incredibly fast, Prettier-compatible code formatter that works with your existing Prettier or oxfmt config.

bun fmt
src/index.ts
src/styles.css
Formatted 2 files, 2 unchanged [5.59ms]

It formats JavaScript, TypeScript, JSX, Flow, CSS, SCSS, Less, HTML, Vue, Svelte, Angular, Lightning Web Components, MJML, JSON, JSONC, JSON5, YAML, TOML, Markdown, MDX, GraphQL and Handlebars. Embedded code is formatted too, like <style> in HTML, code blocks in Markdown and css`...` templates in JavaScript.

In Prettier's own repository (2,616 files, with their config), bun fmt --check takes 60 ms and Prettier takes 17 seconds:

ToolTimePeak memory
bun fmt59.7 ms48 MB
Prettier 3.9.1017.1 s459 MB

That's 286x faster. On vitest, date-fns, monkeytype and h3, it's 234x to 425x faster than Prettier 3.9.9.

On projects that already use oxfmt, with their own config, it's 3x to 6x faster than oxfmt 0.74.0:

ProjectFilesbun fmtoxfmt 0.74.0FasterMemory
RSSHub7,12197.3 ms298.5 ms3.0x42 vs 95 MB
redocly-cli3,39762.4 ms217.5 ms3.4x61 vs 107 MB
vite2,53765.2 ms406.7 ms6.2x64 vs 137 MB
fumadocs2,45244.6 ms1,395 ms31x48 vs 179 MB

Both tools see the same files, exit 0 and leave the tree unchanged.

Reproduce these numbers

Each project is a fresh clone at a pinned commit. The oxfmt projects have no node_modules.

vite

bun install -g oxfmt@0.74.0
git clone https://github.com/vitejs/vite && cd vite
git checkout 4ca25e1d3a24
hyperfine --warmup 2 "oxfmt ." "bun fmt ."

redocly-cli

git clone https://github.com/Redocly/redocly-cli && cd redocly-cli
git checkout 82da67d6ea5b
hyperfine --warmup 3 --runs 20 "oxfmt --check ." "bun fmt --check ."

RSSHub

git clone https://github.com/DIYgod/RSSHub && cd RSSHub
git checkout 8776927c78de
hyperfine --warmup 3 --runs 20 "oxfmt --check ." "bun fmt --check ."

fumadocs

git clone https://github.com/fuma-nama/fumadocs && cd fumadocs
git checkout bc86bbb0d406
hyperfine --warmup 3 --runs 20 "oxfmt --check ." "bun fmt --check ."

prettier

git clone https://github.com/prettier/prettier && cd prettier
git checkout df39b5b60a28
bun install --ignore-scripts
hyperfine --warmup 3 "bun fmt --no-script . --check" "node node_modules/prettier/bin/prettier.cjs . --check"

The same runs on one core, and on only vite's JavaScript and TypeScript files ('**/*.{js,mjs,cjs,jsx,ts,mts,cts,tsx}'):

Runbun fmtoxfmt 0.74.0Faster
vite, 1,608 JS and TS files52.2 ms188.3 ms3.6x
RSSHub, one core621.6 ms1,212 ms1.9x
redocly-cli, one core375.1 ms645.5 ms1.7x
fumadocs, one core255.2 ms5,608 ms21x
  • vite: 16-core M4 Max MacBook Pro, macOS 26.7. Mean.
  • Everything else: Intel Xeon Platinum 8488C, 8 physical cores pinned with taskset, Amazon Linux 2023, xfs on NVMe, warm file cache. Median.

bun fmt wouldn't exist without Prettier, Biome and Oxc. It's a port of Prettier, and the JavaScript and TypeScript printer is a port of oxc_formatter, which is a port of Biome's formatter.

Thank you to the Prettier, Biome and Oxc teams for years of great work.

Read more in the docs.

bun fmt --check#

--check lists the files that need formatting without writing anything. It exits with code 1 if there are any.

bun fmt --check
src/index.ts
src/styles.css
2 files need formatting. Run bun fmt to fix them.

bun fmt --staged and --changed#

--staged only formats the files you're about to commit and re-stages them, so you don't need lint-staged.

.git/hooks/pre-commit

#!/bin/sh
bun fmt --staged

--changed formats the files that differ from your default branch. Pass a ref to compare against something else.

bun fmt --changed
bun fmt --changed=origin/dev

Use your Prettier or oxfmt config#

bun fmt reads .prettierrc*, prettier.config.*, "prettier" in package.json, overrides, .editorconfig and .prettierignore. It also reads .oxfmtrc.json, .oxfmtrc.jsonc and oxfmt.config.ts (or .mts). It accepts the same CLI flags as Prettier or oxfmt, and // prettier-ignore comments work.

"scripts": {
  "format": "prettier --write ."
  "format": "bun fmt"
}

Import sorting is built in, so you can leave @trivago/prettier-plugin-sort-imports, @ianvs/prettier-plugin-sort-imports and prettier-plugin-organize-imports in plugins. Languages that need a Prettier plugin, like Astro, fall back to the Prettier installed in your project.

The command is bun fmt, like cargo fmt, go fmt, deno fmt and zig fmt. bun format still runs your format script.

bun fmt --doctor shows how Bun interprets your config and which packages you can uninstall.

Options in bunfig.toml#

All of Prettier's options are supported under the same names. Each one is also available as a flag, like --print-width 100 or --no-semi. bun fmt --init creates the [fmt] section.

bunfig.toml

[fmt]
printWidth = 100
semi = false
sortImports = true
sortTailwindcss = true
sortPackageJson = true
jsdoc = true

[[fmt.overrides]]
files = ["*.test.ts"]
options = { printWidth = 120 }

sortImports, sortTailwindcss, sortPackageJson and jsdoc are off by default, and don't need a Prettier or oxfmt config. sortImports groups imports, sorts the names in { } and merges duplicate imports from the same module.

In a monorepo, bun fmt walks up from the working directory to the nearest bunfig.toml with a [fmt] section. Without a Prettier config, bun fmt also formats TOML. It skips *.min.js, *.min.css and lockfiles unless you pass them explicitly.

Prettier's Tailwind, package.json and Svelte plugins are built in#

bun fmt does the work of prettier-plugin-tailwindcss, prettier-plugin-packagejson and prettier-plugin-svelte, so you don't need them installed. You can leave them in plugins, and their options are supported.

// Before
<button className="text-white px-4 sm:px-8 py-2 bg-sky-700 hover:bg-sky-800" />
// After
<button className="bg-sky-700 px-4 py-2 text-white hover:bg-sky-800 sm:px-8" />

Tailwind classes are sorted with your project's tailwindcss, version 3 or 4. If it isn't installed, they're left as they are.

We compared the output against Prettier 3.9.9 with version 0.8.1 of the plugin, on Tailwind 4.3.3 and 3.4.19.

InputTotalIdentical%
JavaScript and TypeScript files13,95213,65497.9%
HTML, Vue, Angular, LWC, Handlebars, CSS, SCSS, Less, Markdown and MDX files2,1762,14898.7%
Tailwind 3 projects1515100%

About half of the differences are intentional. In className={`header${open ? " active" : ""}`}, the plugin trims " active" to "active", so the element ends up with the class headeractive. bun fmt keeps the space.

For package.json, keys are sorted in the same order as the plugin and values are left untouched. Of 13,670 real-world package.json files, 13,644 (99.8%) are byte-for-byte identical to the output of Prettier 3.9.9 with the plugin. 20 differ only in values that Bun preserves, and the plugin throws on the other 6.

.svelte files are formatted by default, and the output matches prettier-plugin-svelte 4.1.1. With a Prettier config, they're formatted if the config lists the plugin. To skip them, set svelte = false in [fmt] or pass --no-svelte.

If your config uses a plugin that bun fmt doesn't support, like prettier-plugin-jsdoc, it prints an error naming the plugin, skips the files the plugin applies to and exits with code 2.

Where bun fmt intentionally differs from Prettier#

Prettier's output changes the meaning of the code for these inputs. bun fmt preserves it.

InputPrettier 3.9.9bun fmt
font-family: Font\ Awesome in CSSFont\Awesome, where \A is a hex escapeKept
x |b in a CSS selectorx|b, a namespace selector instead of a descendantKept
<!DOCTYPE html> in HandlebarsDroppedKept
A <script> with a // comment in HandlebarsLines joined, so the comment swallows the next lineKept

bun fmt --debug-check re-parses each file after formatting and fails if the meaning changed, like Prettier's --debug-check.

Testing bun fmt#

Bun's CI runs Prettier 3.9.9's own formatting tests on every commit. A test passes when the output is byte-for-byte identical to Prettier's.

64 of the 83 differences are one intentional case: Prettier drops trailing blank lines from a YAML block scalar, which changes the string.

LanguageTestsIdentical%
JavaScript1,3571,35699.9%
JSX186186100%
TypeScript756756100%
Flow1,5051,50399.9%
JSON785785100%
CSS16216098.8%
Less23823699.2%
SCSS13613599.3%
GraphQL10410298.1%
YAML1,6211,55495.9%
Markdown1,5721,56999.8%
MDX3232100%
HTML27226998.9%
Vue189189100%
Angular109109100%
Handlebars128128100%
MJML, LWC, misc579579100%
Total9,7319,64899.1%

Another 90 tests are skipped. 66 of them use syntax proposals at stage 2 or below that only Babel parses, and 13 need a Prettier plugin.

With an oxfmt config, bun fmt matches oxfmt 0.72's output on 1,343 of oxfmt's 1,498 fixtures (89.7%).

On 330 open-source repositories, we ran each project's format command with its own formatter and with bun fmt. A run only counts as identical if every file is byte-for-byte the same. Next.js counts as a mismatch: 26,334 files are identical, but bun fmt rejects one file that uses a Babel-only proposal.

Compared withRunsIdentical%
oxfmt 0.7224022794.6%
Prettier745777.0%

Most of the Prettier mismatches come from an older version of Prettier, Prettier plugins or Markdown files.

New in bun check#

bun check is 1.4x to 7.8x faster on large projects#

Cold runs with 8 threads on a Linux x64 machine, best of 3. Both versions report the same number of errors on each project.

ProjectBun v1.4.4Bun v1.4.3Faster
MUI docs1.31 s10.28 s7.8x
t3code server1.62 s4.59 s2.8x
Supabase studio1.27 s2.84 s2.2x
mikro-orm1.82 s3.30 s1.8x
VS Code2.82 s4.09 s1.4x

Peak memory on MUI's docs drops from 11.2 GB to 1.3 GB. The other four use up to 0.3 GB more.

Of 76 other repositories, 69 are more than 10% faster. Bun's own src/js is slower: 0.20 s, up from 0.11 s.

  • A switch with thousands of cases, or a variable with thousands of assignments that's read from closures, no longer takes seconds to check.
  • On Windows, files load 31% to 45% faster in projects with an isolated install. That's 50 to 100 ms per run on opencode's packages.

bun check -b is up to 15x faster with many projects#

With bun check -b, projects share parsed declaration files, module resolution caches and the list of emitted files. Each project used to keep its own copy, so check time grew much faster than the number of projects.

bun check -b on generated monorepos with skipLibCheck: true, best of 6 runs on a 16-core M4 Max:

MonorepoBun v1.4.4Bun v1.4.3Faster
1,000 independent projects, 5 files each0.53 s2.07 s3.9x
4,000 independent projects, 5 files each0.87 s13.65 s15.7x
800 projects with up to 40 references each, 20 files each1.26 s7.36 s5.8x

In the last one, peak memory dropped from 1,987 MB to 872 MB.

New in bun install#

bun install migrates Yarn Berry lockfiles#

bun install converts a Yarn 2+ (Berry) yarn.lock to bun.lock and preserves every pinned version. Bun v1.4.3 only migrated Yarn v1 lockfiles, so switching a Berry project to Bun re-resolved every dependency.

bun install
bun install v1.4.4
[59.59ms] migrated lockfile from yarn.lock
  • Workspaces, npm: aliases, resolutions, and git, tarball and file: dependencies are preserved.
  • patch: dependencies become patchedDependencies in package.json.
  • catalog: ranges are read from .yarnrc.yml. The catalogs are copied into package.json.

The migration is all-or-nothing. If Bun can't preserve everything yarn.lock pins, it prints the reason and falls back to resolving from package.json. For example:

  • An exec: dependency.
  • A registry set in .yarnrc.yml but not in bunfig.toml or .npmrc.

bun install retries with exponential backoff#

bun install retries failed requests up to five times. In Bun v1.4.3, all five retries happened within 5 ms, so a brief registry outage would fail the install.

Now it waits 50 ms before the first retry and doubles the delay each time: 50, 100, 200, 400 and 800 ms, with Β±20% jitter.

Installing one package from a misbehaving registry, and how long the command took:

RegistryBun v1.4.4Bun v1.4.3
One 503, then OKworks, 78 msworks, 22 ms
503 for 100 msworks, 222 msfails, 8 ms
503 or 429 for 1 sworks, 1.6 sfails, 7 ms
503 for 3 sfails, 1.5 sfails, 18 ms
Nothing listeningfails, 1.6 sfails, 15 ms

To tolerate outages longer than 1.5 seconds, increase the initial delay:

# waits 250 ms, 500 ms, 1 s, 2 s and 4 s
BUN_CONFIG_HTTP_RETRY_BACKOFF=250 bun install
  • BUN_CONFIG_HTTP_RETRY_BACKOFF=0 retries immediately, like before.
  • Retry-After headers are respected, up to 60 seconds.
  • 429 and 408 responses are retried.

133 bun install issues closed#

We had 597 open package manager pull requests. 464 landed in full and 40 landed partially.

Every one of these PRs had tests that failed in the previous version of Bun and now pass. This nearly doubled bun install's test suite.

We also fuzzed it:

  • We compared the old and new versions on 600 generated projects covering every kind of dependency, both linkers and both lockfile formats.
  • We ran 7 commands against 90,000 mutated bun.lockb files without a crash or hang.
  • We compared version selection against npm-pick-manifest on 200,000 generated cases. It differs in two: a latest tag that points to a missing version, and ^1.0.0-a.0.

Hoisted installs from a warm cache are up to 3.2x faster on Linux#

The hoisted linker links packages on up to 8 threads on macOS and Linux. It helps most when the cache is warm and node_modules is missing or incomplete, like a CI job that restores Bun's cache.

Animation, in 50x slow motion, of bun install filling node_modules for a create-t3-app project. In Bun v1.4.4, folders like @trpc/server, drizzle-orm, next and zod are linked in parallel, and the install takes 91 ms. In Bun v1.4.3, the same folders are linked one at a time, and it takes 162 ms. Bun v1.4.4 is 1.8x faster.create-t3-app, 102 packages, warm cache, Linux

Installing 1,000 packages with 30 files each on Linux, with everything already cached:

node_modulesCPU coresAfterBefore
Empty8120 ms381 ms
Empty4174 ms383 ms
Empty2162 ms222 ms
Half installed854 ms140 ms
Half installed465 ms174 ms
Half installed2107 ms161 ms

Linux installs skip native packages built for the other libc#

Packages like sharp, @swc/core, lightningcss and rollup publish one optional dependency per platform, and mark each Linux build with "libc": ["glibc"] or "libc": ["musl"]. Bun previously ignored that field, so every Linux install downloaded both builds. Now it only installs the one that matches your system, like npm and pnpm.

bun install --libc musl # for an Alpine image, from a glibc machine
bun install --libc '*'  # both builds, like before

The field is saved in bun.lock. Lockfiles written by older versions of Bun don't have it, so existing packages pick it up when they're updated or when you regenerate the lockfile.

Peer dependencies resolve more like pnpm#

When nothing in your project provides a package's peer dependency, Bun installs a version that satisfies the peer range. Before, the package got whatever version an unrelated package happened to install.

package.json

{
  "dependencies": {
    "@modelcontextprotocol/sdk": "1.20.0",
    "@remix-run/express": "2.17.1"
  }
}

@remix-run/express has a peer dependency on express@^4.20.0. The MCP SDK depends on express@5.

Version@remix-run/express gets
Bun v1.4.4express@4
Bun v1.4.3express@5, with an incorrect peer dependency warning

Installing elysia alongside @jest/schemas had the same problem: elysia got @sinclair/typebox@0.27 and failed to load.

How bun install picks a peer dependency's version:

  1. If your project, the workspace or any ancestor of the package depends on the peer, that version is used, even if it's out of range. With react@17 in your app, every library it uses gets react@17.
  2. Otherwise, every package with that peer dependency shares a single version that satisfies all of them. Bun prefers a version that's already installed, then the newest one in the registry.
  3. If no single version satisfies all of them, Bun still avoids installing one copy per package, because packages like react and graphql break when two copies are loaded. Each package uses an installed version in its range if there is one, and otherwise shares the version the others use.

When an install has to resolve dependencies, --verbose prints every peer dependency that ended up out of range.

Versions already in bun.lock don't change. To re-resolve a peer, remove the package that needs it and add it back.

bun update --latest only needs to run once. In Bun v1.4.3, updating react-dom@17 to react-dom@19 changed its peer range, but a react that was only installed as its peer stayed on 17 until the second run.

bun pm fetch#

bun pm fetch downloads every dependency into the cache and nothing else. It doesn't create node_modules, run scripts or touch the lockfile.

In a Dockerfile, this keeps the downloads in a layer that's only rebuilt when package.json or bun.lock changes:

Dockerfile

COPY package.json bun.lock ./
RUN bun pm fetch

COPY . .
RUN bun install --offline --frozen-lockfile

In a monorepo, the first step only needs the root package.json and the lockfile.

bun pm cache pack, unpack and prune#

pack writes the cache entries your lockfile needs into a single file, and unpack restores them on another machine.

# after an install
bun pm cache pack .ci/bun-cache.pack
# on another runner, before the install
bun pm cache unpack .ci/bun-cache.pack
bun install --frozen-lockfile

unpack only adds what's missing, and rejects a corrupted pack entirely.

prune removes old packages from the cache without clearing all of it:

bun pm cache prune             # older than 30 days
bun pm cache prune --max-age 7 # older than 7 days
bun pm cache prune --dry-run   # list what would go

bun publish --recursive and --filter#

Publish every package in a monorepo with one command. Dependencies are published before the packages that depend on them.

bun publish --recursive --quiet
skipped acme: private
skipped @acme/core@1.2.0: already on the registry
skipped @acme/docs: private

@acme/ui
Tag: latest
Access: default
Registry: https://registry.npmjs.org/

 + @acme/ui@1.2.0
  • --filter "@acme/*" only publishes matching workspaces.
  • Private packages and versions already on the registry are skipped, so it's safe to re-run after a failure.
  • Every package is packed before the first one is published.

packageExtensions#

Some packages import a dependency they never declare, which fails with isolated installs. packageExtensions in the root package.json adds the missing entry without a fork or a patch.

package.json

{
  "packageExtensions": {
    "legacy-plugin@2": {
      "dependencies": { "lodash": "^4.17.21" }
    },
    "react-redux@7": {
      "peerDependencies": { "react": "*" }
    }
  }
}

It uses the same format as pnpm and Yarn, and Bun copies the setting over when it migrates a pnpm-lock.yaml or yarn.lock.

link:./dir symlinks a directory into node_modules, like in Yarn and pnpm. The path is relative to the package.json that declares it. You don't need to run bun link first.

package.json

{
  "dependencies": {
    "design-tokens": "link:./vendor/design-tokens",
    "shared": "link:../shared"
  }
}

link:shared, without a path, still refers to a package registered with bun link.

bun install warns about deprecated packages#

bun add rimraf@3
warn: deprecated rimraf@3.0.2: Rimraf versions prior to v4 are no longer supported

Deprecated direct dependencies each print one line. --verbose also lists transitive ones.

Like npm, Bun prefers non-deprecated versions when resolving a range, and bun add pkg skips a deprecated latest. Versions already in bun.lock don't change until you run bun update.

minimumReleaseAge supports units and per-version exclusions#

bunfig.toml

[install]
minimumReleaseAge = "3d" # also "90m", "12 hours", "2 weeks"
minimumReleaseAgeExcludes = ["@types/bun", "typescript@5.9.3"]

A plain number is still interpreted as seconds. name@version excludes a single release, like a security fix you need today, while the rest of that package's versions still have to wait.

Both are also available as flags: --minimum-release-age 3d and the new --minimum-release-age-excludes typescript@5.9.3.

--env-file for the package manager, and new bunx flags#

bun install --env-file ci.env
bunx --env-file ci.env prisma migrate deploy
bunx --cwd packages/api prisma migrate deploy

bun install --env-file ci.env used to try to install a package named ci.env.

  • --env-file <file>, --no-env-file and env = false in bunfig.toml work with every package manager command.
  • bunx --cwd <dir> runs the tool in another directory.
  • bunx --minimum-release-age <seconds> applies the minimum release age to packages that bunx downloads.
  • bunx --trust runs lifecycle scripts for the package and all of its dependencies.

bun add --dev moves existing dependencies#

bun add -d, --optional and --peer move a package that's already listed in another dependency group. They used to leave it where it was.

bun add -d typescript          # "dependencies" -> "devDependencies"
bun add --save-prod typescript # and back

bun install reads the .npmrc that actions/setup-node writes#

actions/setup-node writes its registry and token settings to a temporary .npmrc and points NPM_CONFIG_USERCONFIG at it. Bun reads that variable, so private registries work without copying the file.

.github/workflows/ci.yml

- uses: actions/setup-node@v4
  with:
    registry-url: https://npm.pkg.github.com
- run: bun install
  env:
    NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

bun install understands npm, Yarn and pnpm flags, and rejects unknown ones#

bun install, bun ci, bun add, bun remove and bun update translate flags from other package managers when Bun has an equivalent. Bun v1.4.3 ignored them, so bun add --save-dev zod saved to dependencies and bun install --immutable updated the lockfile.

FlagBun equivalent
--immutable--frozen-lockfile
--package-lock-only--lockfile-only
--save-dev, --save-exact, --save-optional, --save-peer--dev, --exact, --optional, --peer
--save-catalog--catalog
--no-optional, --ignore-optional--omit=optional
--only=production--omit=dev
--location=global--global
--shamefully-hoist, --node-linker=hoisted, --install-strategy=hoisted--linker=hoisted
--node-linker=isolated, --install-strategy=linked--linker=isolated

Flags with no Bun equivalent, like --no-package-lock or --network-timeout 100000, are ignored along with their value. Bun v1.4.3 only ignored the flag itself, so bun install --network-timeout 100000 tried to add a package named 100000.

Flags that none of these tools support are an error.

bun install --frozen-lockfil
error: bun install has no --frozen-lockfil flag.
note: Run 'bun install --help' for more information

Package manager commands and bunx used to ignore unknown flags, so this typo would silently run a normal install in CI.

More in bun install#

  • bun update --depth 0 only updates the dependencies in your package.json. Transitive dependencies keep the versions in bun.lock.
  • bun prune --check prints what bun prune would remove without deleting anything, and exits with code 1 if there's anything to remove.
  • publishConfig.registry and publishConfig["@scope:registry"] set the registry that bun publish publishes to.
  • Like npm 11, bun publish refuses to publish a prerelease version like 2.0.0-beta.1 without --tag or publishConfig.tag, instead of tagging it latest.
  • workspace:* can refer to the root package in bun install, bun pm pack and bun publish.
  • npm-shrinkwrap.json is migrated, like package-lock.json.
  • bun.lock records which packages have install scripts ("hasInstallScript": true), so installing from a committed lockfile reports blocked scripts. Older versions of Bun ignore the new key and drop it the next time they save the lockfile.
  • When bun install --frozen-lockfile or bun ci fails because package.json changed, the error names the dependency, such as is-odd was added or is-number changed from ^6.0.0 to ^7.0.0.
  • --dry-run works with bun link, bun unlink, bun pm version, bun pm cache rm, bun pm trust, bun pm pkg and bun pm migrate. Before, they accepted the flag but still wrote changes.
  • bun pm pkg set and delete support arrays (keywords[]=cli, contributors[0].name=Jane) and bracketed keys (exports[./package.json]).
  • bun pm view and bun info work outside of a project, accept multiple fields at once, and support npm-style paths like maintainers[0].name.
  • bun info, bun pm view, bun pm whoami and bun audit retry failed requests like bun install does, instead of failing on a single 503 or dropped connection.
  • bun pm ls accepts package names (bun pm ls lodash), --production, --omit and --depth=0.
  • bun pm version sets $npm_old_version, $npm_new_version, $npm_command and $npm_package_version for the preversion, version and postversion scripts.
  • Lifecycle scripts of dependencies get their own npm_package_name, npm_package_version, npm_package_json and npm_package_config_*, plus npm_lifecycle_event, npm_lifecycle_script and npm_config_local_prefix.
  • "gypfile": false in a package disables the implicit node-gyp rebuild.
  • bun create <template> supports --ignore-scripts. It also skips the template's preinstall and postinstall tasks when ignoreScripts = true is set in bunfig.toml or ignore-scripts=true in .npmrc.
  • bunx -p pkg cmd installs pkg, then runs cmd with pkg's binaries first in PATH, like npx.
  • Auto-install reads registries and credentials from .npmrc.
  • Auto-install uses the versions pinned in bun.lock or bun.lockb.
  • Auto-install reads the project's package.json the same way bun install does: devDependencies, peerDependencies, overrides, resolutions and catalogs are applied, and workspace:, file: and link: dependencies are loaded from disk.
  • Security scanners receive every resolved dependency, including git, GitHub, tarball, file: and link: ones.
  • Cache-Control: no-cache, no-store and a max-age under 5 minutes on registry metadata are respected.
  • --use-system-ca works with bun install and the other package manager commands. Only NODE_USE_SYSTEM_CA=1 worked before.
  • BUN_INSTALL, XDG_CACHE_HOME, BUN_INSTALL_CACHE_DIR, BUN_INSTALL_GLOBAL_DIR, BUN_INSTALL_BIN and NPM_CONFIG_USERCONFIG accept a leading ~/.
  • bun audit fix reports and rewrites an overrides rule that pins a package to a vulnerable version.
  • bun pm migrate on a Yarn v1 lockfile with 20,000 entries takes 0.27 s, down from 24 s.
  • bun install in a monorepo with 2,100 workspaces peaks at 35 MB of memory, down from 91 MB.
  • Applying a patch no longer takes quadratic time. bun patch --commit with 40,000 changed lines in a 100,000-line file takes 0.09 s instead of 1.45 s.
  • With globalStore = true, a no-op bun install no longer deletes and re-creates every node_modules/.bun/<pkg> link. The store rebuilds its entries once after this upgrade, and bun pm cache rm frees the old ones.

Thanks to @nikelborm for the contribution!

New in Bun.SQL and bun:sqlite#

Bun.SQL uses up to 48% less CPU per query#

We reworked Bun.SQL, bun:sqlite and node:sqlite in a single change that replaces 149 open pull requests and fixes 51 issues.

Every query allocates less memory and executes fewer instructions on the client.

Client-side CPU usage with PostgreSQL, measured in user-space instructions on Linux x64:

OperationChange
Point SELECT-48%
100-row SELECT-30%
INSERT-21%
Bulk insert, 1,000 rows x 10 columns-38%

The same bulk insert uses 53% fewer instructions on MySQL.

Garbage-collected objects allocated per await sql`select 1`:

AdapterAfterBefore
PostgreSQL2884
MySQL2776
SQLite3352

query.cancel() cancels a running PostgreSQL query#

cancel() was already in the types and docs, but it didn't do anything. Now Bun asks the server to cancel the query, and the promise rejects with the server's error.

const query = sql`SELECT pg_sleep(30)`.execute();
setTimeout(() => query.cancel(), 100);

await query; // rejects: canceling statement due to user request

A query that hasn't been sent yet is never sent, and rejects with ERR_POSTGRES_QUERY_CANCELLED, ERR_MYSQL_QUERY_CANCELLED or ERR_SQLITE_QUERY_CANCELLED. On MySQL and MariaDB, a query that's already running on the server still runs to completion.

COPY streams for PostgreSQL#

query.writable() writes to COPY ... FROM STDIN, and query.readable() reads from COPY ... TO STDOUT. Both are web streams with backpressure, so memory usage stays flat no matter how large the table is.

// Load a CSV file into a table
await Bun.file("users.csv")
  .stream()
  .pipeTo(sql`COPY users (id, name) FROM STDIN (FORMAT csv)`.writable());

// Stream a table as an HTTP response
new Response(sql`COPY users TO STDOUT (FORMAT csv, HEADER)`.readable());

The streams carry raw bytes in whichever format the statement specifies: text, csv or binary. A 1,000 MiB COPY streams at 255 MiB/s with a peak RSS of 59 MiB.

Thanks to @gohryt for the contribution!

Column name transforms in Bun.SQL#

transform: SQL.camel converts column names in both directions: first_name is read as firstName, and firstName is written as first_name. It works with every adapter.

import { SQL } from "bun";

const sql = new SQL({ transform: SQL.camel });

await sql`INSERT INTO users ${sql({ firstName: "Alice" })}`;
// INSERT INTO users ("first_name") VALUES ($1)

const [user] = await sql`SELECT first_name FROM users`;
user.firstName; // "Alice"
  • SQL.camel, SQL.pascal and SQL.kebab convert in both directions.
  • SQL.toCamel only converts results, and SQL.fromCamel only converts names passed to sql() helpers. pascal and kebab have the same variants.
  • { column: { from, to } } accepts custom functions.

The names match postgres.js.

Thanks to @pjb3 for the contribution!

Pass JavaScript arrays to PostgreSQL array columns#

You can pass a JavaScript array or an Int32Array anywhere PostgreSQL expects a built-in array type, including arrays with nulls and nested arrays. Both of these queries used to fail.

await sql`SELECT * FROM users WHERE id = ANY(${[1, 2, 3]})`;

// tags text[]
await sql`INSERT INTO posts ${sql({ title: "Hello", tags: ["bun", "sql"] })}`;

35 more built-in array types, such as uuid[], int4range[] and tsvector[], are returned as arrays instead of the string "{...}".

For an array of a user-defined type such as an enum, use sql.array(values, "mood").

result.columns and result.statement#

On PostgreSQL, MySQL and MariaDB, results include the column metadata reported by the server. Use type to distinguish columns that produce the same JavaScript value, such as jsonb and text[].

const result = await sql`SELECT id, tags FROM posts WHERE id = ${1}`;

result.columns;
// [
//   { name: "id", type: 23, table: 16388, number: 1 }, // int4
//   { name: "tags", type: 1009, table: 16388, number: 2 }, // text[]
// ]

result.statement.string; // the text Bun sent, with $1 for the parameter
result.statement.types; // [23]

MySQL and MariaDB columns are { name, type, table, length, flags }. Both properties are null on SQLite.

sql.json()#

sql.json(value) stores any value as JSON and works with every adapter. Use it for numbers, booleans, strings or null.

await sql`INSERT INTO settings (data) VALUES (${sql.json(42)})`; // 42
await sql`INSERT INTO settings (data) VALUES (${sql.json("dark")})`; // "dark"
await sql`INSERT INTO settings (data) VALUES (${sql.json(null)})`; // JSON null

A bare null is still SQL NULL.

bun --hot reuses database connections#

Every save under bun --hot used to open up to max new PostgreSQL or MySQL connections and leave the old ones open. Now a new SQL() with the same settings reuses the connections from before the reload.

With max: 2, five reloads used to leave 12 sessions open on the server. Now there are 2.

Session state like SET and temporary tables is preserved. onconnect runs again for each connection that's reused.

Thanks to @eriklangille for the contribution!

New Bun.SQL connection options#

const sql = new SQL("postgres://app@db.example.com/app?connect_timeout=5", {
  channelBinding: "require",
  tls: {
    ca: Bun.file("./ca.pem"),
    checkServerIdentity: (hostname, cert) =>
      cert.fingerprint256 === pin ? undefined : new Error("pin mismatch"),
  },
});
  • channelBinding: "disable" (the default), "prefer" or "require". It binds PostgreSQL SCRAM authentication to the server's TLS certificate (SCRAM-SHA-256-PLUS). Also available as ?channel_binding= and PGCHANNELBINDING.
  • tls.checkServerIdentity(hostname, cert): replaces the built-in hostname check on PostgreSQL, MySQL and MariaDB, like in node:tls. Passing it upgrades any SSL mode below verify-ca to verify-full.
  • foundRows: true: on MySQL and MariaDB, affectedRows counts the rows an UPDATE matched instead of the rows it changed, like mysql2. Also available as ?foundRows=true.
  • ?connect_timeout=, ?idle_timeout=, ?max_lifetime= and ?max= in PostgreSQL and MySQL URLs.
  • libpq's URL parameters on PostgreSQL: host, port, user, password, dbname, sslrootcert, sslcert, sslkey and sslpassword. Bun used to forward them to the server, which refused the connection.
  • PGAPPNAME, PGOPTIONS and PGCONNECT_TIMEOUT environment variables.

User-defined functions in bun:sqlite#

db.function() defines a SQL function that calls a JavaScript function.

import { Database } from "bun:sqlite";

const db = new Database(":memory:");
db.function("add2", (a, b) => a + b);

db.query("SELECT add2(12, 4) AS sum").get(); // { sum: 16 }

db.aggregate() defines an aggregate function such as sum().

db.aggregate("add_all", {
  start: 0,
  step: (total, value) => total + value,
});

db.query("SELECT add_all(dollars) AS total FROM expenses").get();
  • Both accept the varargs, deterministic, directOnly and safeIntegers options.
  • inverse lets an aggregate be used as a window function.
  • Errors thrown by a function propagate to the get(), all() or run() call that ran the query.

The signatures match better-sqlite3.

Thanks to @nikeee and @jdjkelly for the contribution!

Update hooks in bun:sqlite#

Set db.onInsert, db.onUpdate or db.onDelete to a function, and SQLite calls it for each row that this Database inserts, updates or deletes.

db.onInsert = (db, tableName, rowid) => {
  console.log(`inserted ${tableName} ${rowid}`);
};

db.run("INSERT INTO cats (name) VALUES ('Salem'), ('Keanu')");
// inserted cats 1
// inserted cats 2

Set it to null to remove the hook.

Also new:

  • stmt.reader is true if the statement returns rows.
  • stmt.readonly is true if the statement doesn't directly modify the database.
  • db[Symbol.for("sqlite-type")] is "bun:sqlite". Libraries can use it to distinguish a Database from a node:sqlite DatabaseSync.

Thanks to @jecquas and @himself65 for the contribution!

New in WebSocket and ws#

ws behaves like the npm package on Node.js#

import "ws" in Bun loads a built-in implementation backed by Bun's native WebSocket client and server. We rewrote it as a port of ws 8.22.0. Framing, masking, UTF-8 validation and compression are still native.

Compared to npm ws on Node.jsBun v1.4.4Before
ws's own test suite, 271 tests23494
212 client scripts, byte-for-byte identical output17918
159 server scripts, byte-for-byte identical output15067

The remaining failures depend on ws internals that the native implementation doesn't have, like ws._receiver and generateMask.

import { WebSocket, createWebSocketStream } from "ws";

const ws = new WebSocket("wss://example.com/logs", {
  handshakeTimeout: 5_000,
  followRedirects: true,
  maxPayload: 1024 * 1024,
});

createWebSocketStream(ws, { encoding: "utf8" }).pipe(process.stdout);
  • createWebSocketStream() is implemented, with backpressure. It used to throw "Not supported yet in Bun".
  • agent, createConnection and lookup are respected, so SOCKS proxy agents work. A custom agent used to be ignored, and the client connected directly.
  • handshakeTimeout, closeTimeout, followRedirects, maxRedirects, origin, auth, protocolVersion, autoPong and skipUTF8Validation are implemented instead of silently ignored.
  • maxPayload is enforced on clients and servers. socket.io's maxHttpBufferSize depends on it.
  • WebSocketServer({ perMessageDeflate }) negotiates compression.
  • WebSocketServer accepts any Duplex. This includes http2.createSecureServer({ allowHTTP1: true }) (vite --https), server.emit("connection", socket) (socket.io sticky sessions) and fastify's injectWS.
  • Server sockets have pause(), resume() and isPaused. They're instanceof WebSocket, and ws._socket is the node:http socket.

ws events match Node.js#

ws.on("error", (error) => {
  error instanceof Error; // true, was an ErrorEvent
  error.code; // "ECONNREFUSED"
});

ws.on("close", (code, reason) => {
  reason; // a Buffer, was a string
});
  • An 'error' event without a listener is an uncaught exception, like in Node.js. Add an 'error' listener to every client and every server socket.
  • 'close' fires when the connection closes, with the peer's code and reason. close() without a code reports 1005, and a failed connection reports 1006.
  • Error messages match the npm package's, like Unexpected server response: 403.
  • send() throws while the socket is CONNECTING.
  • ws servers have no idle timeout, no automatic pings and no backpressure limit. They used to inherit Bun.serve's defaults: a 120 second idle timeout, a ping at 104 seconds and a 16 MiB limit.
  • The default maxPayload is 100 MiB, up from 16 MiB.

WebSocket waits for the closing handshake#

The close event fires once the connection has closed, like in browsers and Node.js. It used to fire right after close() with your own code and wasClean: true, regardless of what the server did.

const ws = new WebSocket("wss://example.com");
ws.onopen = () => ws.close();

ws.onclose = (event) => {
  event.code; // the server's code, 1005 if its Close frame had none
  event.wasClean; // false if the server never sent a Close frame
};
  • close() without a code sends an empty Close frame. Both ends report 1005 instead of 1000, including the close(ws, code) handler in Bun.serve.
  • A script that ends with ws.close() takes one extra round trip to exit. If the server doesn't respond within 30 seconds, close fires with 1006.
  • BUN_CONFIG_WS_CLOSE_TIMEOUT changes the 30 second timeout. ws.terminate() closes immediately.

Failed WebSocket connections fire error, then close with 1006#

A bad handshake, a TLS or proxy error, a protocol error, an oversized message and invalid UTF-8 are all reported the same way, like in browsers and Node.js. event.error.code is new.

const ws = new WebSocket("ws://localhost:1");

ws.onerror = (event) => {
  event.error.code; // "ECONNREFUSED"
};

ws.onclose = (event) => {
  event.code; // 1006
  event.reason; // ""
  event.wasClean; // false
};

Before, close fired with 1002, 1007, 1009, 1011 or 1015 and an English reason, sometimes with wasClean: true, and often no error event fired.

  • event.message has the description that used to be in reason, with more detail: Expected 101 status code: 503, TLS handshake failed: self signed certificate.
  • event.error.code is a Node.js-style code such as ENOTFOUND, DEPTH_ZERO_SELF_SIGNED_CERT, ERR_TLS_CERT_ALTNAME_INVALID or WS_ERR_INVALID_UTF8.
  • Losing the TCP connection after open isn't treated as a failure. Only close fires, with 1006.

ws.pause() and ws.resume() in Bun.serve#

Use these to slow down a client that sends faster than you can process. While a socket is paused, Bun stops reading from it, so the client sees TCP backpressure.

Bun.serve({
  fetch(req, server) {}, // upgrade logic
  websocket: {
    message(ws, message) {
      if (!file.write(message)) {
        ws.pause();
        file.once("drain", () => ws.resume());
      }
    },
  },
});

ws.isPaused tells you whether the socket is paused. The idle timeout is suspended while a socket is paused.

websocket.error in Bun.serve receives the socket#

When a websocket handler throws, Bun calls error with the error and the socket it came from. It only received the error before, and it was missing from the types.

Bun.serve({
  fetch(req, server) {}, // upgrade logic
  websocket: {
    message(ws, message) {
      throw new Error("oops");
    },
    error(error, ws) {
      ws.close(1011);
    },
  },
});

Every handler is optional, including message.

proxy.tls: separate TLS options for an HTTPS proxy#

With an https:// proxy, tls applies to both the proxy and the server. Pass proxy.tls to configure them separately. The proxy then only uses proxy.tls, and the server only uses tls.

const ws = new WebSocket("wss://example.com", {
  proxy: {
    url: "https://proxy.internal:8443",
    tls: { ca: Bun.file("./proxy-ca.pem") },
  },
});

Thanks to @steipete, @hughescr, @davidhevn and @deepshekhardas for the contributions!

New in the runtime#

JavaScriptCore upgrade#

This release upgrades JavaScriptCore with 1,013 upstream WebKit commits.

Measured on an Apple silicon Mac, best of several runs:

OperationBun v1.4.4Bun v1.4.3Faster
n.toLocaleString("en-US"), 100,000 calls25 ms1,341 ms54x
Loading an array literal of 1,000,000 integers11 ms45 ms4.2x
for (const ch of str), 1,000,000 characters, some non-Latin-11.2 ms3.2 ms2.7x
new Array(n) with n under 4, 10,000,000 calls31 ms61 ms2.0x
try { throw "x" } catch {}, 1,000,000 times37 ms72 ms2.0x
try { throw new Error("x") } catch {}, 1,000,000 times128 ms174 ms1.4x
new WeakMap() and one set(), 1,000,000 times7.7 ms9.9 ms1.3x

toLocaleString() with a locale and no options reuses a cached Intl.NumberFormat, which makes it as fast as creating the formatter yourself and calling format(). This applies to numbers and BigInts.

  • using is allowed in a function nested inside a switch case. It was a SyntaxError.
  • +1n * 2n throws a TypeError. It used to return 2n.
  • +a * b() calls a.valueOf() before b().
  • for...in over a function visits its indexed properties, like fn[0].
  • JIT-optimized (x + y + 0.5) | 0 returns the right result when x + y overflows 32 bits.
  • JIT-optimized code throws a RangeError for new Uint8Array(-1) even if the array is never used.
  • In sloppy mode, label: function f() {} is hoisted like an unlabeled function declaration.
  • WebAssembly's baseline compiler keeps a function's most-used locals in registers.

Bun.Glob supports extglob and brace ranges#

const source = new Bun.Glob("**/!(*.test).ts");
source.match("src/cart.ts"); // true
source.match("src/cart.test.ts"); // false

new Bun.Glob("src/@(app|lib)/**/*.ts").match("src/app/a/b.ts"); // true
new Bun.Glob("shard-{1..3}.json").match("shard-2.json"); // true
PatternMatches
?(a|b)Zero or one of the patterns
*(a|b)Zero or more of the patterns
+(a|b)One or more of the patterns
@(a|b)Exactly one of the patterns
!(a|b)Anything in a path segment except the patterns
{1..3}, {01..10}, {a..e}, {1..9..2}A number or letter in the range. The third value is the step.

The new syntax works everywhere Bun uses globs, including --filter and workspaces, files and sideEffects in package.json.

package.json

{
  "workspaces": ["packages/!(internal)"]
}

In Bun v1.4.3, this failed with error: Workspace not found "packages/!(internal)".

A ( that isn't preceded by ?, *, +, @ or ! is still a literal character, so Next.js route groups like app/(marketing)/** still match.

These patterns used to be matched literally. They now have a different meaning, consistent with picomatch, minimatch and bash's extglob:

PatternBun v1.4.3Bun v1.4.4
*(1).txtMatched copy(1).txtZero or more 1, then .txt. Write *[(]1).txt instead.
!(g)/a.tsEverything but (g)/a.tsa.ts in any folder but g
{1..3}The text 1..31, 2 or 3

This only affects patterns, not paths. A file with those characters in its name still matches **/*.ts.

Bun Shell is unaffected: quoted text and ${} values are still treated literally.

Up to 2.8x faster Headers iteration#

for...of, entries(), keys(), values(), forEach() and Object.fromEntries(headers) are 1.7x to 2.8x faster. Adapters that copy a Response's headers into node:http, like @hono/node-server, do this on every request.

for (const [name, value] of response.headers) {
  res.setHeader(name, value);
}

Time to iterate over one Headers object on an M4 Max, best of 7 runs:

Operationv1.4.4v1.4.3Faster
for...of, 3 headers98 ns277 ns2.8x
for...of, 14 headers627 ns1.41 Β΅s2.2x
for...of, 14 custom X- headers1.24 Β΅s2.08 Β΅s1.7x
headers.forEach(), 14 headers505 ns1.10 Β΅s2.2x
[...headers.keys()], 14 headers261 ns688 ns2.6x
[...headers.values()], 14 headers295 ns680 ns2.3x
Object.fromEntries(headers), 14 headers579 ns1.46 Β΅s2.5x

Up to 2.6x faster response.body streams for in-memory bodies#

Streaming a body that's already in memory (new Response("...").body, request.body, blob.stream()) is 1.5x to 2.6x faster. Reading a 100-byte body allocates 7 KB instead of 270 KB.

for await (const chunk of new Response(html).body) {
  res.write(chunk);
}

Time to read an entire new Response(string).body with reader.read(), on Linux x64:

BodyAfterBefore
100 B1.41 Β΅s3.66 Β΅s
4 KB1.77 Β΅s4.15 Β΅s
16 KB2.93 Β΅s5.54 Β΅s
64 KB9.11 Β΅s14.33 Β΅s
1 MB176 Β΅s272 Β΅s

Adapters that copy a Response into a node:http response read the body this way. A Hono app on @hono/node-server handled a batch of 24 requests in 16.0 ms, down from 24.0 ms.

--disallow-code-generation-from-strings=strict only runs code that ships with your program#

=strict used to block a fixed list of APIs. Now JavaScriptCore refuses to run any code that isn't a Bun built-in or one of your program's own files, regardless of which API it came from.

For a bun build --compile executable, only the code inside the executable counts as your program. JavaScript and TypeScript files on disk no longer run, including files the program just wrote:

const path = "/tmp/plugin.js";
await Bun.write(path, "console.log('hi')");
await import(path);
// EvalError: Code generation from strings disallowed for this context

Without --compile, files on disk count as your program, so that import still runs.

=strict also blocks:

  • Plugins that return contents, for any loader. Return an exports object instead.
  • Macros.
  • In a compiled executable, --preload in BUN_OPTIONS and .txt or .md imports from disk.

.json, .toml and .yaml files still load from disk. import() of a data: URL containing JSON is no longer blocked.

New in the dev server#

Redesigned error overlay in the dev server#

The error overlay for Bun.serve with HTML imports has a new design, and similar errors in the same file are grouped into one card. Three missing fonts in a stylesheet used to show up as three separate blocks.

BUILD FAILED
4 errors in 2 files

app.ts                                         1 ERROR
  ERROR  Could not resolve: "./render"
      1  import { render } from "./render";

styles.css                                    3 ERRORS
  ERROR  Could not resolve                          Γ—3
      3  src: url("./fonts/inter-regular.woff2");
      8  src: url("./fonts/inter-medium.woff2");
     13  src: url("./fonts/inter-bold.woff2");
  • The overlay scrolls. Errors past the bottom of the window used to be unreachable.
  • Build errors are shown once, not twice, when a file is bundled for both the server and the browser, like a stylesheet imported by a page.
  • There's a light theme that follows your system setting.
  • Runtime errors use the same design, with the error's name as the title.

Other improvements#

  • Improved: req.headers and res.setHeader() have less overhead in node:http servers. 256 requests through Express 5 take 9.45 ms, down from 9.91 ms (Linux x64).
  • Improved: Bun.JSON5.parse() is up to 1.6x faster. A 126 KB bun.lock parses in 0.42 ms, down from 0.67 ms (M4 Max).
  • Improved: Bun.Glob.match() is 1.6x to 5x faster. Matching **/*.{ts,tsx} against src/components/button/index.tsx takes 32 ns, down from 158 ns (M4 Max).
  • Improved: root certificates are updated to NSS 3.131.1.

  • Improved: native addons on Linux and macOS can call 47 more libuv functions, including uv_async_*, uv_timer_*, uv_idle_*, uv_prepare_*, uv_check_*, uv_queue_work, uv_default_loop and uv_cwd. Bun used to exit with "unsupported uv function".

  • Improved: echoing a 1 MB non-ASCII WebSocket text message in Bun.serve takes 72% fewer CPU instructions.

  • Improved: Bun.markdown.html() on large non-ASCII input takes 33% fewer CPU instructions.

  • Improved: Bun.hash() and its algorithms are 1.2x to 2.3x faster on short strings. Bun.hash.crc32() on a 16-character ASCII string takes 8 ns, down from 15 ns (M4 Max).

  • Improved: Bun.CryptoHasher, Bun.SHA256 and the other hashers are 1.2x to 1.7x faster on small inputs. Hashing 16 bytes to hex with Bun.SHA256.hash() takes 77 ns, down from 107 ns (M4 Max).

  • Improved: new TextDecoder() with an options object, like new TextDecoder("utf-8", { fatal: true }), is 2x faster.

  • Fixed: a regression in Bun v1.4.3 where runs that write the compile cache (NODE_COMPILE_CACHE or module.enableCompileCache()) took 2x longer to exit (580 ms instead of 300 ms for a 3.4 MB module).
  • Improved: Bun's FreeBSD binaries are built against FreeBSD 14.4. The previous target, 14.3, has reached end of life.

Security#

This release includes security fixes for TLS, WebSocket, the database clients and the package manager. These protocols, APIs and commands are affected:

TLS, HTTP/1.1, HTTP/2, HTTP/3, WebSocket, Brotli, Bun.serve, Bun.listen, Bun.connect, Bun.SQL, Bun.RedisClient, Bun.password, Bun.Image, Bun.Archive, node:tls, node:https, node:http, node:http2, node:net, node:quic, node:crypto, node:vm, node:v8, ws, fetch, TextEncoder, bun install, bunx, bun patch, bun publish, bun pm pack, bun pm trust, bun audit and bun create.

Bugfixes#

Node.js compatibility improvements#

  • Fixed: various stability issues in node:http, node:http2, node:quic, node:dgram, node:dns, node:crypto, node:vm, node:stream, node:zlib, node:util, node:diagnostics_channel, node:worker_threads and native addons.

node:http

  • Fixed: in a node:http server, res.end() threw ERR_INVALID_ARG_TYPE when res.statusCode was a numeric string like "404" or a fraction like 200.5. It now sends 404 and 200, like Node.js.
  • Fixed: in a node:http server, res.end() sent 200 when res.statusCode was NaN or undefined. It now throws ERR_HTTP_INVALID_STATUS_CODE, like Node.js.
  • Fixed: in a node:http server, after res.end({}) threw because of the invalid chunk, res.headersSent was true even though nothing had been sent, so res.setHeader() threw ERR_HTTP_HEADERS_SENT.

node:http2

  • Fixed: pushStream() in node:http2 threw ERR_HTTP2_OUT_OF_STREAMS instead of passing it to the callback.
  • Fixed: with http2.createSecureServer() and server.emit("connection", socket), session.socket wasn't a TLSSocket and 'connection' listeners didn't fire.

node:fs

  • Fixed: on Linux and macOS, every fs.realpath() variant treated \ as a path separator, so a file named a\b threw ENOENT, or resolved to a/b if that existed.
  • Fixed: on Linux and macOS, fs.realpath.native(), fs.realpathSync.native() and fs.promises.realpath() collapsed .. before following symlinks, so link/../y resolved next to the link instead of its target, and missing/.. didn't throw.
  • Fixed: on Linux and macOS, fs.realpath() and fs.realpathSync() threw ENOTDIR for a regular file's path with a trailing slash, like file/. Like Node.js, they now return the file's path.
  • Fixed: on Windows, fs.promises.realpath("C:\\") returned C: instead of C:\.

node:vm

  • Fixed: vm.createContext(vm.constants.DONT_CONTEXTIFY) returned a wrapper instead of the context's global. Top-level var and function declarations were lost, and Vitest with jsdom 30.1 failed to start.
  • Fixed: in a vm.createContext(vm.constants.DONT_CONTEXTIFY) context, scripts threw a ReferenceError for names inherited from the global's prototype chain, including through a Proxy. jsdom 30 with runScripts relies on this.

node:stream

  • Fixed: Readable.fromWeb(stream).destroy() emitted 'error' when the web stream's cancel() rejected. Node.js doesn't.

node:crypto

  • Fixed: an X25519 CryptoKey couldn't be cloned with structuredClone() or sent with postMessage().
  • Fixed: write() on a node:crypto Hash or Hmac threw ERR_CRYPTO_HASH_FINALIZED after digest(). The chunk is now dropped, like in Node.js.

node:util

  • Fixed: calling new on a native function that isn't a constructor, like util.types.isDate, returned the result of calling it. It now throws a TypeError, like Node.js. These functions were also printed as [class isDate].
  • Fixed: util.types.isProxy(globalThis) returned true.

process

  • Fixed: registering the same function twice with process.on() only added it once. It's now added and called twice, like in Node.js.
  • Fixed: after import * as process from "node:process", process.on() didn't add the listener to process.

node:diagnostics_channel

  • Fixed: in node:diagnostics_channel, the next subscriber was skipped when a subscriber unsubscribed itself during publish().
  • Fixed: in node:diagnostics_channel, a subscriber added during publish() was called during that same publish().
  • Fixed: channel.unsubscribe() threw a TypeError when channel._subscribers held a frozen or sealed array.

node:worker_threads

  • Fixed: in a Worker with env: SHARE_ENV, join(), slice() and concat() treated holes as empty in an array with process.env in its prototype chain. arr[1] already read process.env[1].

Native addons

  • Fixed: loading a NODE_MODULE addon in a Worker when the main thread has already loaded it now throws ERR_DLOPEN_FAILED, like Node.js.
  • Fixed: when a Worker stopped while a native addon's async work was still running, the addon's finalizers ran before that work was done. They now run after it, like in Node.js.

Other modules

  • Fixed: the write() callback on a node:net or node:tls socket was never called if the connection closed while the write was still pending. It's now called with ECANCELED, like in Node.js.

  • Fixed: os.userInfo() threw TypeError: Type error when passed a number or a string. It now behaves like Node.js.

  • Fixed: os.setPriority(priority, undefined) threw. It now behaves like Node.js.

  • Fixed: socket.connect() in node:dgram failed with ERR_INVALID_ARG_TYPE when the port was a numeric string like "41234". Node.js accepts it.

  • Fixed: zlib.crc32(data, undefined) threw ERR_INVALID_ARG_TYPE. It now starts from 0, like zlib.crc32(data).

  • Fixed: http.maxHeaderSize = "5000000000" wrapped to 705032704. The setter now only accepts a safe integer of 1 or more, and a string like "16384" throws ERR_INVALID_ARG_TYPE.

  • Fixed: new SourceMap() from node:module threw ERR_INVALID_ARG_TYPE when mappings was an empty string, like in the source map of a chunk that only re-exports.
  • Fixed: Profiler.takePreciseCoverage in node:inspector returned function and block ranges that didn't match V8's, so tools like v8-to-istanbul marked the wrong lines. A skipped ?? fallback() was counted as executed.

Bun APIs#

  • Fixed: various stability issues in Bun.serve, Bun.spawn, FileSink, Bun.S3Client, Bun.RedisClient, Bun.YAML, Bun.JSON5, Bun.markdown, Bun.Cookie, Bun.WebView, HTMLRewriter, bun:ffi, Bun.gzipSync(), Bun.deflateSync() and Bun.unsafe.arrayBufferToString().

Bun.serve

  • Fixed: an error thrown by a Bun.serve request handler in a Worker is now printed, like on the main thread.

Bun.hash and Bun.CryptoHasher

  • Fixed: Bun.hash() and its algorithms returned the hash of an empty input when given a Bun.file(), an S3 file or a Bun.build() output written to outdir. They now throw a TypeError.
  • Fixed: hashing an S3 file with Bun.CryptoHasher, Bun.SHA1.hash(), Bun.sha() or crypto.hash() returned the digest of an empty input. It now throws a TypeError, like it does for Bun.file().
  • Fixed: Bun.hash(), Bun.hash(undefined) and Bun.hash(null) returned the hash of "", "undefined" and "null". They now throw a TypeError, and so do Bun.hash.crc32() and the other algorithms.
  • Fixed: accessing the static byteLength on a subclass of Bun.MD5, Bun.SHA256 or another hasher threw a TypeError, and so did algorithms on a subclass of Bun.CryptoHasher.

Bun.Glob

  • Fixed: Bun.Glob.match() treated ** inside braces like *, so {src/**,lib/**} didn't match src/a/b.ts.
  • Fixed: Bun.Glob.match() returned false for a pattern with more than 10 brace groups, or with braces nested more than 10 deep.
  • Fixed: Bun.Glob's scan() and scanSync() treated a truthy option that isn't true as false. { dot: 1 } skipped dotfiles and { onlyFiles: 1 } listed directories.

Other Bun APIs

  • Fixed: Bun.JSON5.parse() accepted two invalid inputs. "1\0 2" parsed as 1 and "{a\\u002d: 1}" as { a: 1 }. Both throw a SyntaxError.
  • Fixed: Bun.markdown.ansi() and bun file.md could break a line in the middle of a word made of several parts, like `code` followed by . or ( followed by a link.
  • Fixed: .ptr on a bun:ffi cc() symbol wasn't the address of the C function.

  • Fixed: Bun.YAML.parse() returned null and Bun.TOML.parse() returned {} when given an S3 file. They now throw a TypeError, like they do for Bun.file().

  • Fixed: Bun.gzipSync() and Bun.deflateSync() ignored the memLevel and strategy options and always compressed with the defaults. A memLevel outside 1 to 9 or a strategy outside 0 to 4 now throws.

  • Fixed: Bun.CSRF.generate(undefined, options) threw "Secret is required", so there was no way to pass options and keep the default secret.
  • Fixed: Bun.$.escape() returned non-string arguments like null, true or new String("foo") unchanged when they didn't need escaping. It now always returns a string.
  • Improved: expiresIn: NaN in Bun.CSRF.generate(), maxAge: NaN in Bun.CSRF.verify() and a NaN port in Bun.dns.prefetch() throw a RangeError instead of falling back to the default.
  • Fixed: Bun.indexOfLine() returned -1 when its first argument wasn't a buffer, such as a string that contains a newline. It now throws a TypeError.
  • Fixed: Bun.JSONL.parseChunk() reported a read offset before a value it had already returned when that value was followed by a stray ,, ] or }. Regression in v1.4.3.
  • Fixed: Bun.JSONL.parse() and Bun.JSONL.parseChunk() returned a value more than once when it was preceded by blank lines and followed by a stray ,, ] or }. Regression in v1.4.3.
  • Fixed: assetPrefix on a Bun.FileSystemRouter was always undefined. It now returns the assetPrefix option, or null if it isn't set.
  • Fixed: query and params from Bun.FileSystemRouter's match() listed numeric keys twice, so Object.values(router.match("/?0=a").query) returned ["a", "a"].
  • Fixed: graph.dispose() on a Bun.ModuleGraph leaked the graph's modules when its code still had a Bun.Terminal open or a node:http server listening. The terminal's file descriptors leaked too.

Web APIs#

  • Fixed: various stability issues in experimental HTTP/2 fetch(), ReadableStream.from(), crypto.subtle, structuredClone, URL, URLSearchParams, URLPattern, Headers and FormData.

fetch

  • Fixed: fetch() with keepalive: false, or a keepAlive: false Bun.FetchSession, left its HTTP/1.1 connection open in the pool when the request also had a Connection: keep-alive header, which happens when you forward req.headers.

Streams and Blob

  • Fixed: new Response(blob, { headers }).blob() set type on the original Blob when it had none.
  • Fixed: slice() without a type on a body Blob kept the parent's type instead of "".

Other Web APIs

  • Fixed: Reflect.construct() of WebSocket, BroadcastChannel or Worker still connected, subscribed or started the thread when the newTarget's prototype getter threw.

TLS#

  • Fixed: various stability issues in TLS clients and servers.

  • Improved: Bun.serve, Bun.listen and node:tls servers now only request a client certificate when requestCert: true is set, which matches the docs and Node.js. Setting ca alone no longer does. For mTLS, set both.

  • Fixed: a tls.createServer() with SNICallback never negotiated ALPN. ALPNProtocols and ALPNCallback were ignored, so HTTP/2 clients fell back to HTTP/1.1.

  • Fixed: the ws package ignored top-level TLS options such as rejectUnauthorized, ca, cert and key, so connecting to a self-signed wss:// server failed with TLS handshake failed. pfx is also supported.

  • Fixed: node-fetch ignored the TLS options (ca, cert, key, rejectUnauthorized) of an https.Agent passed as agent.

  • Fixed: fetch(), tls.connect() and other TLS clients failed to connect to servers with an Ed25519 certificate.

  • Fixed: a self-signed certificate that isn't marked as a CA, like the ones dotnet dev-certs creates, wasn't trusted when passed in NODE_EXTRA_CA_CERTS or ca.

  • Fixed: a TLS server configured with multiple key and cert pairs only served the last one. With an RSA and an ECDSA pair, each client now gets one it supports.

  • Fixed: assigning tls.DEFAULT_CIPHERS had no effect on fetch, WebSocket, Bun.connect, RedisClient, Bun.SQL and S3Client.

  • Fixed: Bun.SQL with sslmode=prefer timed out against a Postgres server without TLS. It now falls back to an unencrypted connection, as documented.

  • Fixed: Bun.SQL ignored tls.servername, the node:tls spelling of serverName.

  • Fixed: tls.connect() with autoSelectFamily added an 'end' listener for each address it tried, and printed MaxListenersExceededWarning after nine.

  • Fixed: the tls options key, cert, ca, crl and ALPNProtocols read the entire underlying ArrayBuffer when given a view into part of it, like buffer.subarray(n).

WebSocket#

  • Fixed: various stability issues in the WebSocket client and ws.

WebSocket client

  • Fixed: a client WebSocket's send(), ping() and pong() sent the text [object BuildArtifact] when given an in-memory Bun.build() output. They now send its bytes, like ServerWebSocket.

  • Fixed: ws.send() right before ws.close() could lose messages on ws:// if the server was still sending, while close reported a clean 1000.

  • Fixed: on wss:// through a proxy, Close and Pong frames could be sent ahead of queued messages, which were then lost even though the close was reported as clean.

  • Fixed: a wss:// WebSocket connected through a proxy closed with 1006 when a frame was sent while a TLS 1.2 server was renegotiating. Direct connections weren't affected.

  • Fixed: in rare cases under backpressure, the end of the send queue was never flushed and bufferedAmount stayed above 0.

  • Fixed: event listeners didn't run in the AsyncLocalStorage context of the new WebSocket() call.

  • Fixed: new WebSocket(url, { protocols: "chat" }) threw "Value is not a sequence", and protocol: "chat" was ignored.

  • Fixed: ws://:token@host sent no credentials.

  • Fixed: one invalid entry in proxy.headers silently dropped all of them, including Proxy-Authorization. It now throws instead.

  • Fixed: a proxy's 407 was reported as "Proxy connection failed", and a CONNECT reply of HTTP/1.1 200 with no reason phrase, or any 2xx other than 200, was rejected.

  • Fixed: 100 Continue or 103 Early Hints before the 101 failed the handshake.

  • Fixed: send(), ping() and pong() with Bun.file() or an S3 file sent an empty frame. They now throw.

  • Fixed: non-ASCII header values were sent as UTF-8. They're now sent as Latin-1, like fetch and Node.js.

  • Fixed: several WebSocket TypeScript types. error and onerror receive an ErrorEvent, the ping and pong events and binaryType: "blob" are declared, and headers accepts a Headers object.

Bun.serve WebSocket servers

  • Fixed: Safari closed the connection with 1002 when a server with perMessageDeflate: true sent compressed messages.
  • Fixed: perMessageDeflate.compress set to "dedicated" or "4KB" through "256KB" was treated as "3KB". Each value now uses its own window size, which uses more memory: "dedicated" goes from 149 KB to 282 KB per connection.
  • Fixed: perMessageDeflate: { compress: false, decompress: true } still compressed outgoing messages, and decompress: "16KB" configured the compressor.
  • Fixed: server.stop(true) closed WebSockets with 1006. They now close with 1001 "Server closed".
  • Fixed: after a protocol error, invalid UTF-8 or a message over maxPayloadLength, the client saw 1006. It now receives a Close frame with 1002, 1007 or 1009 and a reason.
  • Fixed: idleTimeout: 0 closed idle connections after about 256 seconds instead of never.
  • Fixed: idleTimeout: 65536, maxPayloadLength: 2 ** 32 + 16 and backpressureLimit: 2 ** 32 + 1 wrapped around to 0, 16 and 1.
  • Fixed: maxPayloadLength also applied to Ping, Pong and Close frames.
  • Fixed: ws.close() on a socket over backpressureLimit dropped messages that send() had already accepted, along with the Close frame.
  • Fixed: ws.close() inside ws.cork(), drain, or open after an async upgrade closed the connection 16 seconds late.
  • Fixed: terminate(), a throwing open handler, stop(true) and process exit dropped data that send() or publish() had accepted in the same tick.
  • Fixed: a long ws.close(code, reason) reason was truncated in the middle of a character, so the client saw 1007.
  • Fixed: server.upgrade(req, { headers }) sent Sec-WebSocket-Accept, Upgrade or Connection twice when headers had them, threw for headers: {}, and couldn't decline a subprotocol. An empty Sec-WebSocket-Protocol now declines it.
  • Fixed: an error thrown after server.upgrade() was silently dropped. It's now passed to error() or printed to stderr.
  • Fixed: server.reload() leaked memory for every route of a server with WebSockets, and new websocket options didn't apply to sockets that were already open.

ws

  • Fixed: a ws server sending a burst of large messages delivered some twice and some out of order.
  • Fixed: ws.once("message") together with ws.on("message") delivered each message twice.
  • Fixed: on server sockets, addEventListener("message") received a Buffer for text, and onclose received a number.
  • Fixed: the binary option of send() was ignored for an ArrayBuffer or Blob, and { fin: false } sent whole messages instead of fragments.
  • Fixed: the send() callback reported success for a message dropped after close(). Callbacks now run once the data is written, or with an error if the socket closes first.
  • Fixed: the send(), ping() and pong() callbacks reported success for a frame dropped after the connection ended but before 'close' fired. They now receive ERR_STREAM_DESTROYED, like in Node.js.
  • Fixed: messages that arrived before a deferred handleUpgrade(), after an await or an async verifyClient, were lost.
  • Fixed: in 'unexpected-response', a chunked body arrived undecoded, and a body that came after the response head was lost.
  • Fixed: ws ignored proxyRequestOptions.headers of an hpagent proxy agent, so proxies that require Proxy-Authorization responded with 407.
  • Fixed: headers pushed by a 'headers' listener were missing from the 101 response (engine.io's cookies), and handleProtocols: () => false still selected a protocol (graphql-ws).

node:http and undici

  • Fixed: socket.bytesWritten was 0 on 'upgrade' and 'connect' sockets in node:http. engine.io closes such sockets after a second, so a Vite or SvelteKit dev server with socket.io reloaded the page every second.
  • Fixed: Slack Bolt in Socket Mode reconnected forever, because undici had no ping() export and no undici:websocket:ping and undici:websocket:pong diagnostics channels.

Runtime#

  • Fixed: various stability issues in startup on Linux arm64, garbage collection on Linux and uncaught error reporting.

  • Fixed: the ^ under a syntax error pointed too far left when double-width characters, like CJK text, came before it on the line.

  • Fixed: in JIT-optimized functions, .sort() on 16 or fewer integers returned the wrong result with a comparator like (a, b) => (a + 0.5) - (b + 0.5). Regression since v1.4.0.
  • Fixed: navigator.hardwareConcurrency, os.availableParallelism() and process.constrainedMemory() ignored cgroup v2 limits on Linux hosts that also have a cgroup v1 hierarchy, even just name=systemd. Bun's thread counts and GC heap sizing ignored them too.
  • Fixed: an error thrown right after let copy = value at the top of a block could report an earlier statement's line and column, in functions with more than ~110 constants.
  • Fixed: CallSite#getColumnNumber() counted columns from 0 instead of 1. With @cspotcode/source-map-support installed, reading error.stack threw "column must be greater than or equal to 0" for a frame at column 1.
  • Fixed: error.stack left out the column of a frame at column 1, printing file.js:3 instead of file.js:3:1.
  • Fixed: a stack frame at const x = require("./x") pointed at x instead of the require() call.
  • Fixed: an undefined JSX component was reported at its closing tag instead of its opening tag.
  • Fixed: the source preview of an uncaught error on the last line of a file with no trailing newline left that line out.

bun --hot and bun --watch#

  • Fixed: various stability issues in bun --hot and bun --watch.

  • Fixed: on macOS and Linux, timers under bun --hot stopped firing after an uncaught exception, even after you saved a fix. Reloading also resets the exit code to 0.

bun run#

  • Fixed: various stability issues in bun run, bunx and module resolution.

  • Fixed: when running as PID 1 in a container, bun now exits with 128 + the signal number on Ctrl+C or docker stop.

Transpiler#

  • Fixed: various stability issues in Bun.Transpiler and TypeScript decorators.

  • Fixed: @dec static {} was accepted. A decorator on a class static block is now a syntax error, like in TypeScript and esbuild.

  • Fixed: with standard decorators, when a class decorator returned a new class, the class name inside the class body (decorator arguments, static fields, methods) still referred to the original class.

  • Fixed: hexadecimal, octal and binary number literals above 2^53 could round to the wrong double. For example, 0xb3c8bf81fdf2247 was a different number than in Node.js. Bun.JSONC.parse() had the same bug.
  • Fixed: Bun.Transpiler with exports.eliminate left behind imports that only the eliminated exports used. With trimUnusedImports: false the imports were kept, and TypeScript output was left with a bare import "pkg".

bun check#

  • Fixed: a stability issue in bun check.

  • Fixed: bun check reported false TS2456 and TS2502 circularity errors on Slate's CustomTypes module augmentation (Editor: BaseEditor & ReactEditor). That turned Editor into any and hid real errors.

  • Fixed: some bun check errors depended on the number of threads, like a false TS2321 ("Excessive stack depth comparing types") with @sinclair/typebox on two or more threads.

  • Improved: bun check follows TypeScript 7.1 and no longer reports TS2321. After 100 nested comparisons, it assumes the types are related.

  • Fixed: bun check reported TS18046 and TS7006 errors that tsc doesn't when narrowing the result of .then() on a Promise<T> | PromiseLike<T> with "catch" in result. This affected tRPC.
  • Fixed: bun check reported a false TS2589 ("excessively deep") on a call that left out an optional parameter whose type is infinitely deep, like TanStack Router's useLoaderData() on recursive JSON data.
  • Fixed: bun check reported a false TS2589 ("excessively deep") when an object built with a spread, inside a class or generic function, went through a chain of generic calls, like in ts-pattern.
  • Fixed: bun check reported a false TS2615 ("circularly references itself in mapped type") after a type guard narrowed a class whose method takes immer's Draft<this>.
  • Fixed: bun check reported false TS2344, TS2345, TS2339 and TS7031 errors for some generic types that refer to each other in a cycle and use infer.
  • Fixed: bun check reported a false TS2322 when assigning to a union of intersections where one member holds an unresolved conditional type whose constraint is never.
  • Fixed: bun check reported a false TS2322 when a generic function typed through a generic alias (Id<string>) is passed to a generic wrapper and the result is assigned to the same alias.
  • Fixed: bun check reported a false TS2554 when a class method calls an imported function that has the same name as a type parameter and a method of that class.
  • Fixed: bun check reported an extra TS2322 on the return of a function whose return type annotation references itself, on top of TS2577.
  • Fixed: bun check reported a TS7023 error that tsc doesn't for a generator without a return type that yields a variable in a loop and reassigns it from a call to itself.
  • Fixed: bun check gave a class declared in a loop a wider type than tsc when its extends expression reads a variable the loop reassigns, which could produce a TS2322 that tsc doesn't report.
  • Fixed: bun check reported TS7022 where tsc reports TS2339 for a destructured parameter with contextual type unknown whose default reads another binding, like take({ first, second = first }).
  • Fixed: bun check printed {0} in place of the file names in TS18000 when tsconfig.json files extend each other circularly.
  • Fixed: bun check missed errors that tsc reports while inferring the type of a variable that's reassigned in a loop, like TS2322 on last = last.next = item. This also covers TS2367, TS2774, TS2349 and TS2344.
  • Fixed: bun check missed TS2367 on a comparison like y === 5 (with y: string) in a value assigned to a loop variable that is read earlier in the loop body.
  • Fixed: bun check missed TS7022 when a loop reassigns its variable from a value that depends on that variable through a class property, or from a for...of variable, like const { children } = current; for (const child of children) current = child.
  • Fixed: bun check missed TS2502 for a setter whose parameter annotation reads the property it declares, like set degrees(value: (Celsius | Kelvin)["degrees"]).
  • Fixed: bun check missed TS2589 when a generic function with a self-referential constraint was passed to a generic call, so a // @ts-expect-error above it was flagged as unused.
  • Fixed: bun check missed TS2540 and TS2704 for a readonly property when its type is intersected with a mapped type that removes readonly, like Writable<I> & I.
  • Fixed: bun check missed TS2445 and TS2341 for a protected or private property read through an intersection of its class with a mapped type, like Record<"balance", unknown> & Account.
  • Fixed: bun check took quadratic time in the number of keys of any object in tsconfig.json, usually paths. 40,000 keys took 10.9 s and now take 0.1 s.
  • Fixed: bun check took quadratic time when thousands of paths or files entries in tsconfig.json each had an error. 4,000 TS5090 errors took 35.5 s and now take 0.04 s.
  • Fixed: bun check was slower when the project's directory had tens of thousands of sibling files, like in $TMPDIR or a large ~/code.
  • Improved: bun check -b parses a shared base tsconfig.json once for all projects that extend it. 1,000 projects extending 5,000 paths went from 71 s and 3.08 GB to 1.6 s and 0.62 GB.
  • Improved: bun check -b checks small projects in parallel. It runs up to 16 projects at once instead of 4, so a monorepo of large projects uses more memory.
  • Improved: bun check --no-script runs the type checker even if package.json has a check script. It accepts the type checker's flags (bun check --no-script -b). bun fmt also accepts --no-script.

bun install#

  • Fixed: various stability issues in bun install, bun update, bun outdated and bun run.

Installing

  • Fixed: with the hoisted linker, a dependency removed from package.json stayed in node_modules after bun install, along with packages that only it depended on.
  • Fixed: with the isolated linker, a dependency stayed linked in node_modules and .bin after it was manually deleted from package.json, or removed with bun remove inside a workspace or while another package still depended on it.
  • Fixed: switching between the hoisted and isolated linkers left parts of both layouts in node_modules, plus a .old_modules-* copy of the old tree.
  • Fixed: an install that was killed partway could leave a half-written package in node_modules that later installs treated as installed.
  • Fixed: with the hoisted linker, a file: folder outside the project (file:../plugin) couldn't load its own dependencies, and on Windows the install failed with EPERM.
  • Fixed: file: folders and local tarballs were reinstalled on every bun install with the hoisted linker.
  • Fixed: without a lockfile, bun install --production failed when a workspace:, link: or file: devDependency pointed to a missing folder, like when a Docker image only contains one package from a monorepo.
  • Fixed: bun install failed with Failed to link <pkg>: EEXIST on an up-to-date node_modules that's mounted read-only.
  • Fixed: bun install --dry-run ran the project's lifecycle scripts, and dryRun = true in bunfig.toml was ignored.
  • Fixed: bun install --dry-run printed one line per dependency edge, skipped peers and ignored the platform. It now lists each package the install would add to node_modules once.
  • Fixed: bun install --analyze and bun add --analyze skipped packages that are only imported by subpath, like lodash/debounce.
  • Fixed: with the isolated linker, an optional dependency whose tarball couldn't be downloaded failed the install. It's now skipped.
  • Fixed: bun install <pkg>, bun link and bun update -i exited with code 0 after printing Failed to install N packages.
  • Fixed: a relative --cwd was applied twice by bun update, bun link, bun unlink and bun why, so they failed with ENOENT.
  • Fixed: bun install left extraction directories in $TMPDIR when two installs shared a cache or a tarball failed to extract, which filled the disk on long-lived CI machines.
  • Fixed: two installs sharing a cache on NFS could delete each other's entries and fail with ENOENT.
  • Fixed: with NODE_ENV=development or test, bun install read .env.production instead of the .env files that bun run reads.
  • Fixed: bun --bun run only worked for the first user on a shared machine.
  • Fixed: on Alpine Linux (musl), Bun.which() returned a file that isn't executable.

Resolving versions

  • Fixed: 1.0.0 || ^2.0.0 resolved to 1.0.0. A range that starts with an exact version was parsed as only that version.
  • Fixed: bun add, bun install and bun update --latest failed for a package whose latest dist-tag is missing or points to an unpublished version. They now fall back to the highest release.
  • Fixed: dist-tags that start with a digit, such as pkg@1beta, were parsed as version ranges.
  • Fixed: an auto-installed peer dependency got the newest version that one dependent allows. It now gets the newest version that all of them accept.
  • Fixed: a peer dependency on bun downloaded the bun npm package, which is 80 to 170 MB.
  • Fixed: a version published after Bun cached the package's metadata couldn't be installed for up to 5 minutes.
  • Fixed: in a monorepo with a lockfile, adding a dependency whose name another workspace uses for an npm: alias requested a garbled name, such as GET /1.0.0no-deps - 404.
  • Fixed: editing a workspace's package.json re-downloaded its GitHub and tarball dependencies and re-fetched its git dependencies, even though bun.lock had already resolved them.
  • Fixed: file:, tarball and git packages with no name in their package.json got broken bun.lock entries. They're now named after their source.
  • Fixed: an overrides key with a prerelease range, such as pkg@>=5.0.0-alpha.0 <8, also replaced dependencies on ^4.
  • Fixed: bun add foo and bun update --latest installed a version that a version-scoped override like "foo@>=2.0.0": "1.2.0" forbids.
  • Fixed: Bun.semver.satisfies() returned true for versions that aren't valid semver, such as "", "1.2.3.4" and "1.2.3 junk", and false for 1.0.0+build-1 against ^1.0.0.
  • Fixed: Bun.semver.satisfies() and bun install parsed build metadata after a bare major (^2+x) as a second range, which allowed a different major version.
  • Fixed: Bun.semver.order("2+1", "2.0.0") threw.
  • Fixed: >=0.5 0 was parsed as two alternatives instead of an intersection, and <x and >x matched every version.

Hoisted and isolated linkers

  • Fixed: the hoisted linker could place a package next to a version of its peer dependency that's outside the peer's range.
  • Fixed: with the hoisted linker, --filter, --production, --omit, --os and --cpu changed where packages were placed. Switching flags could leave a workspace loading the wrong version. Placement now only depends on bun.lock.
  • Fixed: with the hoisted linker, a dependency nested under a package with bundledDependencies could load the bundled copy instead of the version it asked for.
  • Fixed: with the hoisted linker, bun prune deleted a package's bundled dependencies when bun.lock didn't mark them as bundled, which affected aws-cdk-lib.
  • Fixed: bun add -g didn't link the package's binaries into the global bin folder when linker = "isolated" was set in ~/.bunfig.toml.
  • Fixed: with the isolated linker, packages that are peer dependencies of each other were installed twice when different workspaces or dependencies pulled them in.
  • Fixed: with the isolated linker and an npm: alias that installs a second copy of a package, node_modules/.bun/node_modules/<name> could point at a different version after each install.
  • Fixed: the isolated linker replaced a symlinked node_modules with a real folder. Both linkers now install through the symlink, and cleanup doesn't delete anything behind it.
  • Fixed: with the isolated linker on macOS and Linux, a workspace whose folder or parent folder is a symlink ended up with dangling dependency symlinks.
  • Fixed: the isolated linker silently dropped symlinks inside a file: folder dependency. Both linkers now keep symlinks that point inside the folder, copy symlinks to other project files as regular files, and skip the rest with a warning.
  • Fixed: with globalStore = true, TypeScript couldn't find the project's @types/* from inside a shared package.
  • Fixed: BUN_INSTALL_GLOBAL_STORE=false (also no, off and an empty value) enabled the global store.
  • Fixed: symlinks to a renamed or removed workspace were left in node_modules.
  • Fixed: with the hoisted linker, bun install didn't link a newly added workspace when the registry package of the same name and version was already in node_modules.
  • Fixed: on Alpine Linux, a package that ships native binaries for both libcs, such as @anthropic-ai/claude-code, could have its command linked to the glibc binary.
  • Fixed: none of a package's binaries were linked if one value in its bin object wasn't a string.
  • Fixed: when several packages declare the same binary, which one ended up in .bin was arbitrary. Direct dependencies now take priority.
  • Fixed: a binary that's generated by the package's own install script, like a workspace CLI built in prepare, wasn't linked into .bin until the next bun install.
  • Fixed: bun install failed on file systems that don't support symlinks. It now warns once and skips the .bin links.

Lockfile and --frozen-lockfile

  • Fixed: a fresh bun install could write a different bun.lock depending on which registry response arrived first. Dependencies are now resolved in a deterministic order.
  • Fixed: bun ci failed in an unchanged monorepo when a workspace had a lifecycle script and a dependency on latest, as soon as a newer version of that dependency was published.
  • Fixed: bun ci applied minimumReleaseAge to a version already pinned in bun.lock and failed when package.json expressed the same version differently, such as with catalog:.
  • Fixed: a direct dependency locked at a version outside its package.json range, which can happen after a bad merge, was installed anyway and passed --frozen-lockfile.
  • Fixed: bun install kept a locked version that an overrides rule forbids, which can also happen after a bad merge. It now re-resolves that entry, and --frozen-lockfile fails with an explanation.
  • Fixed: bun install could write a bun.lock it couldn't read back (Failed to resolve prod dependency) when a package bundles a package that another package lists as an optional peer.
  • Fixed: bun <file>.lockb printed the lockfile from the file's directory instead of the file itself. That broke using it as a git textconv, where git passes a temporary copy.
  • Fixed: bun pm migrate left trustedDependencies out of the bun.lock it wrote, so bun pm untrusted listed trusted packages as blocked.
  • Fixed: a yarn.lock or pnpm-lock.yaml that pins a GitHub dependency failed on the first bun install and lost the pin on the second. The pinned commit is now installed, including for pnpm's type: git resolutions.
  • Fixed: migrating a Yarn v1 lockfile failed with "failed to resolve" on link: and bare path (./dir) entries, and in a monorepo where a registry package depends on a workspace.
  • Fixed: migrating a Yarn v1 lockfile merged a tarball or hosted-git entry with the registry package of the same name and version, so a fork was replaced by the published package.
  • Fixed: a lockfile that Bun can't migrate, like npm 6's package-lock.json, prevented Bun from migrating a yarn.lock or pnpm-lock.yaml next to it. Bun now warns and tries the next one.
  • Fixed: a single package-lock.json entry from another host without an integrity made Bun ignore the whole lockfile. The entry now keeps its version and gets its integrity from the configured registry, with a warning.

Workspaces and catalogs

  • Fixed: a workspace whose package.json has no name failed the whole install. It's now skipped.
  • Fixed: depending on latest of a sibling workspace that isn't published failed with a 404. It now links the workspace.
  • Fixed: a workspace was left out of the install when the root package.json depends on a registry package with the same name. It now keeps its bun.lock entry and its dependencies are installed.
  • Fixed: bun add <name> inside a workspace installed the package from the registry when <name> is also a sibling workspace. It now links the workspace and writes workspace:*.
  • Fixed: bun add alias@npm:<workspace> wrote "alias": "workspace:*", which every later install rejected.
  • Fixed: bun link <package> inside a workspace that has workspace: or catalog: dependencies failed with Workspace dependency "bar" not found or is not in the catalog.
  • Fixed: negated workspaces entries written as !./pkg or !pkg/ were ignored, and bun run --filter and --workspaces didn't apply negated entries.
  • Fixed: workspace globs skipped symlinked directories.
  • Fixed: bun update <name> rewrote non-registry entries in package.json, for example workspace:^ to workspace:*.
  • Fixed: bun run --filter <pkg> deploy still ran deploy and postdeploy after predeploy failed. It now skips them, like bun run without --filter.

Registries, .npmrc and bunfig.toml

  • Fixed: a registry URL with a path and no trailing slash, such as https://host/npm/repo, lost its last path segment, so every request got a 404.
  • Fixed: .npmrc credentials are now matched the same way npm matches them. A //host/ entry applies to every path under it, including a registry set by --registry or an environment variable.
  • Fixed: ~ in cache.dir, globalDir, globalBinDir and cafile created a directory literally named ~.
  • Fixed: a relative cafile, cache, globalDir or globalBinDir in bunfig.toml or .npmrc is now resolved relative to that file. A relative cafile used to only work from the project root.
  • Fixed: a relative --config, --cache-dir or --cafile is now resolved relative to the current working directory instead of the project root.
  • Fixed: -g commands ignored globalDir from --config and ~/.bunfig.toml.
  • Fixed: bun publish and bun pm whoami ignored http_proxy. Those two and bun audit also ignored NODE_TLS_REJECT_UNAUTHORIZED=0.
  • Fixed: a redirected registry request ignored NO_PROXY for the new URL. Each redirect now picks its proxy based on its own URL, like fetch().
  • Fixed: credentials embedded in a registry URL were sent percent-encoded, so p%40ss wasn't sent as p@ss.
  • Fixed: BUN_CONFIG_MAX_HTTP_REQUESTS was ignored.

Tarballs and the cache

  • Fixed: --force and re-adding the same path or URL kept the old contents of a tarball dependency, and bun update left its old hash in bun.lock.
  • Fixed: file: and URL tarballs were cached by path or URL, so two projects or branches with different files at the same path got each other's package.
  • Fixed: two registries on the same host that differ only in port, path or protocol shared cache entries.
  • Fixed: uncompressed .tar packages of 2 MB or more, and .tgz files of that size served with Content-Encoding: gzip, failed to install.
  • Fixed: Bun.Archive's extract(dir, { glob }) and files() resolved with the entries read so far on a truncated or corrupted archive. They now reject, like extract(dir).

Git dependencies

  • Improved: git dependencies now require Git 2.24 or newer.
  • Fixed: a cached git dependency never picked up tags and branches pushed after it was cached. Bumping to a new tag failed with no commit matching, and bun update stayed on the old commit.
  • Fixed: git+ssh://git@host:2222/... lost its port when Bun fell back to ssh.
  • Fixed: a git dependency with an ssh URL never fell back to ssh when the https attempt returned "not found".
  • Fixed: bun add name@git+ssh://host/repo.git failed with unrecognised dependency format for hosts other than GitHub.
  • Fixed: deploy@host:org/repo.git was cloned as git@deploy@host.
  • Fixed: gitlab:, bitbucket:, gist: and sourcehut: dependencies failed to install from bun.lock on a clean machine. They're now written as git+ssh://git@gitlab.com/user/repo#<sha>, and existing entries still install.
  • Fixed: an optional git dependency that can't be cloned failed the install. It's now skipped with a warning.
  • Fixed: bun install and bun create run from a git hook operated on the hook's repository, because GIT_DIR and similar variables were passed on to git.
  • Fixed: bun install overrode your core.sshCommand or core.askPass for git dependencies when it's set outside ~/.gitconfig, like in ~/.config/git/config or an included file.
  • Fixed: submodule.recurse = true in your git config broke a git dependency that has a submodule.
  • Fixed: a cached clone that git can't use, because of a stale lock file or a damaged ref store, failed every install. It's now re-cloned.

Lifecycle scripts and trust

  • Fixed: with the isolated linker, a package added to trustedDependencies after it was installed never ran its scripts.
  • Fixed: a package installed through an npm: alias couldn't be trusted consistently. It's now trusted by its real name in package.json, --trust, bun pm trust and bun.lock.
  • Fixed: --concurrent-scripts and concurrentScripts had no effect with the isolated linker, and the bunfig.toml value took precedence over the flag.
  • Fixed: bun pm untrusted, bun pm trust and bun pm ls --trusted used a stale lockfile instead of package.json.
  • Fixed: bun pm pack, bun publish and bun pm version ran lifecycle scripts without node_modules/.bin in PATH, so "prepack": "tsc" failed with command not found.
  • Fixed: a regression in Bun v1.4.0 where a failed lifecycle script's non-UTF-8 output, such as Latin-1 or Shift-JIS compiler errors, was printed with every invalid byte replaced by U+FFFD.

bun add, bun remove, bun update and bun outdated

  • Fixed: removing the last package in a dependency group with bun remove reordered the other keys in package.json.
  • Fixed: bun add ../path run from a subdirectory of the project, and bun install -g ., resolved the path from the wrong directory.
  • Fixed: bun add with a folder, tarball or git URL wrote the package into bun.lock twice when package.json already listed it from another source.
  • Fixed: the first bun add -g on a machine edited a package.json above the global directory, such as ~/package.json.
  • Fixed: bun add --dry-run in an empty directory created a package.json.
  • Fixed: bun add -E react@18 wrote 18. --exact now pins the range you typed to the installed version, like npm.
  • Fixed: bun add, bun remove, bun update and bun patch rewrote package.json even when nothing changed.
  • Fixed: commands that rewrite package.json turned control characters in strings into \x1B or \v, which isn't valid JSON.
  • Fixed: bun update printed (no changes) when it had rewritten package.json.
  • Fixed: bun update --latest downgraded a dependency written as a dist-tag like "canary" when that tag is ahead of latest. bun update -i no longer offers an older latest either.
  • Fixed: bun update --latest downgraded a catalog entry that was ahead of the latest tag. This affected pinned versions, ranges and dist-tags like canary.
  • Fixed: bun update --interactive rewrote ranges like >=1.0.0 <2, 1.x and ^1 || ^2 as their first character plus the chosen version. They're now left unchanged if they already allow the chosen version.
  • Fixed: bun update --exact pinned peerDependencies ranges, and bun update --latest narrowed a peer range that already allows the latest version.
  • Fixed: bun outdated a b only listed packages matching both patterns. It now lists packages matching either one, and !pattern excludes.
  • Fixed: bun outdated hid dependencies that are ahead of the latest tag. It now lists them if bun update would change them.

bun pm and bun why

  • Fixed: bun pm bin -g and bun pm ls -g failed with No package.json was found before the first global install.
  • Fixed: bun pm ls listed the lockfile's packages under a node_modules label. When node_modules exists, it now lists what's actually installed.
  • Fixed: bun why name@version found nothing for exact versions, scoped names and workspaces.
  • Fixed: bun why printed a package's dependents again at every occurrence, which grew exponentially in large trees. Repeats are now marked *deduped.
  • Fixed: bun pm version didn't create a commit or tag in a git worktree or submodule, where .git is a file.
  • Fixed: bun pm version <increment> now computes the same version as npm version. 1.0.0-rc.1 plus major is 1.0.0, not 2.0.0, and v2.0.0 is written as 2.0.0.
  • Fixed: bun pm view pkg@tag silently used latest for a dist-tag that doesn't exist. It's now an error that lists the available tags.
  • Fixed: bun pm cache rm deleted the cache directory itself, which left a dangling link when it's a symlink and failed with EACCES in a read-only parent.
  • Fixed: bun pm cache rm printed Cleared 'bun install' cache without deleting anything when ~/.bunfig.toml set the cache directory.

bun pm pack and bun publish

  • Fixed: workspace: and catalog: versions came from bun.lock, so they could be stale, and packing failed without a lockfile. They now come from the package.json files.
  • Fixed: a catalog: dependency whose catalog entry is workspace:^ or another workspace: form was published as-is. It's now replaced with the workspace's version.
  • Fixed: bun pm pack and bun publish omitted bundledDependencies with the isolated linker and in workspaces.
  • Fixed: !pattern in .npmignore couldn't re-include files below an excluded directory.
  • Fixed: relative --destination and --filename paths, and bun publish ./x.tgz, were resolved from the package root instead of the current directory.
  • Fixed: bun publish --provenance, publishConfig.provenance and NPM_CONFIG_PROVENANCE were ignored, so the package was published without provenance. They're now errors until Bun supports generating it.
  • Fixed: a file listed under several bin names was packed several times.

bun patch

  • Fixed: a patch that adds a file in a new directory failed with EACCES for non-root users.
  • Fixed: hunks were applied by line number alone, so a patch could land in the wrong place after the file changed. They're now matched by context, like git apply.
  • Fixed: a patch that appends lines to a file lost the last line when the file has no trailing newline before or after the change.
  • Fixed: bun patch --commit on an already patched package wrote .bun-tag files and cache paths into the patch.
  • Fixed: bun patch dropped the package's bundled and nested dependencies, and --commit recorded them as deleted.
  • Fixed: a bun patch that failed partway left a partial package, or none, in node_modules.
  • Fixed: a bun patch --commit whose patch doesn't apply, or whose install is refused, could leave the package folder, package.json or bun.lock changed. Now nothing is modified until every check has passed.
  • Fixed: with the isolated linker, bun patch replaced node_modules/<pkg> with a directory that couldn't load its dependencies.
  • Fixed: bun patch and bun patch --commit failed with ENOENT when the package is installed but no longer in the cache, for example after bun pm cache rm.
  • Fixed: bun patch --commit failed when a patched file or the cache folder has a non-ASCII name.
  • Fixed: bun install silently skipped the parts of a patch it can't apply, like a Binary files ... differ section, and a patchedDependencies key without a version. Each one now prints a warning.

bunx

  • Fixed: bunx pkg@latest relinked everything on every run, and a copy more than a day old was refreshed on every run.
  • Fixed: several bunx <pkg> processes started at the same time corrupted their shared install.
  • Fixed: bunx <tool> with a cached copy older than a day failed when the registry returned 5xx, 429 or 408, or when a newer version's tarball couldn't be fetched.
  • Fixed: bunx pkg@3 failed with could not determine executable to run in a project where another version of pkg is installed and has no such command.
  • Fixed: bunx @scope/pkg could run another package's .bin entry of the same name instead of that package's own command.

bun create

  • Fixed: bun create <local template> <dir> deleted an existing directory. It now merges, lists conflicts and only overwrites with --force.
  • Fixed: git init and the first commit raced the template's tasks and bun install. The first commit now contains exactly the template.
  • Fixed: bun-create tasks ran without a shell, didn't run at all in a template without dependencies, and their failures were ignored.
  • Fixed: the indentation of the template's package.json was lost.

Auto-install

  • Fixed: timers and I/O callbacks could run inside require(), import() and Bun.resolveSync() while auto-install fetched a package.
  • Fixed: importing a second version (pkg@2 after pkg@1) failed with Unexpected when the package's metadata was in the disk cache.
  • Fixed: one failed auto-install download limited fetch() to 4 concurrent requests for the rest of the process.
  • Fixed: peer dependencies of auto-installed packages failed with "Cannot find module".
  • Fixed: with auto-install, an import throws "Cannot find package" when the cache or temp directory can't be written, like in a read-only container.

Windows

  • Fixed: commands whose shebang uses env -S didn't run.
  • Fixed: .bunx shims broke when the package is on another drive.
  • Fixed: bun remove left .bunx and .exe shims behind.
  • Fixed: bun install failed in the root of a drive.
  • Fixed: EINVAL: Failed to replace old lockfile on exFAT and FAT32 drives.
  • Fixed: renames failed while a virus scanner held a new file open. They're now retried for up to 5 seconds.
  • Fixed: bun create <local template> failed with "ENOENT: failed to copy file" when the working directory, the template or the destination has a non-ASCII character.
  • Fixed: the preversion, version and postversion scripts of bun pm version ran with an empty environment in a git repository.

JavaScript bundler#

  • Fixed: various stability issues in bun build, Bun.build plugins and macros.

  • Fixed: bun build --minify gave two default exports the same name when each was a parenthesized named function or class expression, like export default (function inc() {}).

  • Fixed: bun build left an HTML <img src> pointing to the source path without writing the file when a stylesheet inlined the same image through url().

  • Fixed: in source maps from bun build --react-compiler, catch and the { after it mapped to the position of try.

  • Fixed: bun build source maps mapped export default function, export default class, export async function, abstract class, import(), require() and require.resolve() to the wrong column.

bun build --compile#

  • Fixed: various stability issues in bun build --compile and compiled executables.

CSS Parser#

  • Fixed: a stability issue in the CSS parser.

  • Fixed: bun build dropped translate, rotate or scale declared before transform in the same CSS rule, and merged the ones declared after it into transform.

Dev server#

  • Fixed: a stability issue in the dev server.

  • Fixed: bun ./index.html ignored --port 3000 and --host 0.0.0.0 when written with a space instead of =.

  • Fixed: bun ./index.html --host=::1 threw a RangeError.

  • Fixed: bun ./index.html printed a startup error twice, such as the one for an invalid --port.

  • Fixed: the underline under a CSS resolve error for url('/fonts/a.woff2') ended 5 characters early. It now covers the whole url(...), and only the path in composes: x from "./a.css".

bun test#

  • Fixed: various stability issues in bun test and bun test --changed.

  • Fixed: bun test --update-snapshots emptied a snapshot file when the run ended early, like with --bail. The old contents are now kept until the new ones are written.

  • Fixed: bun test --coverage counted skipped code as covered: the right side of ||, &&, ?? or ?: on its own line, code skipped by ?., ||=, &&=, ??= or an unused default value, and an untaken break.

  • Fixed: bun test --coverage counted the lines after an await or yield as covered when the function never resumed past it. % Lines can drop after upgrading, so check your coverageThreshold.

  • Fixed: bun test --coverage listed the wrong Uncovered Line #s for a function that never ran. The range could start two lines early and miss the last line.

  • Fixed: bun test --coverage reported lines that ran as uncovered, such as a break in a block inside while (true), or the import above class Derived extends Base() {}.

  • Fixed: bun test --coverage ignored a line with only default:, or case with its value on the next line, in functions that ran.

  • Fixed: bun test --coverage listed the { line instead of the else line when else and { were on separate lines. Source maps gave else the position of the statement after it.

  • Fixed: bun test --coverage listed lines at the top of a file as code, comments included, when the file used a --define name whose value is a non-empty object or array.

  • Fixed: % Funcs in bun test --coverage counted implicit constructors, class field initializers, static blocks, and generator and async function bodies as functions. A fully tested class could show 50%.

  • Fixed: the DA: hit counts in lcov.info weren't the number of times a line ran. A line that ran once could show 14.

Bun Shell#

  • Fixed: a stability issue in Bun Shell.

  • Fixed: $`cmd` truncated an environment variable's value at a NUL byte. .env() and .cwd() now throw ERR_INVALID_ARG_VALUE for strings containing a NUL byte.

  • Fixed: in Bun Shell, mv -n overwrote an existing destination.

  • Fixed: in Bun Shell, cp -n exited with code 1 when the destination existed.

SQL#

  • Fixed: various stability issues in Bun.SQL, bun:sqlite and node:sqlite.

Bun.SQL connection pool

  • Fixed: idleTimeout and maxLifetime closed a connection that was running a query, was reserved, or had an open sql.begin() transaction. Now the connection finishes its work first.
  • Fixed: a pool that lost some of its connections didn't open new ones as long as one was still usable, so it never grew back to max.
  • Fixed: a connection released to the pool inside an open transaction, such as a reservation after a manual BEGIN, was handed to the next caller. The pool now closes it.
  • Fixed: sql.close({ timeout: 0 }) waited for running queries instead of closing immediately.
  • Fixed: sql.close({ timeout: 2 ** 31 }) overflowed the timer and killed the running queries after 1 ms.
  • Fixed: sql.begin() and sql.reserve() jumped ahead of older queries that were waiting for a connection. Callers are now served in FIFO order.
  • Fixed: onconnect and onclose never fired for the sql.listen() connection.
  • Improved: PostgreSQL and MySQL sockets now use TCP keepalive (60 seconds), so a query fails instead of waiting forever when the server disappears.

Bun.SQL transactions

  • Fixed: in sql.begin(tx => Promise.all(tasks)), after one task failed, statements that the other tasks sent after the ROLLBACK ran in autocommit mode, so their rows were kept. They now reject.
  • Fixed: a query created on a reserved or tx handle but first awaited after release(), COMMIT or ROLLBACK ran on a connection that could belong to another caller. It now rejects.
  • Fixed: reserved.release() during a running reserved.begin() returned the connection early, so that transaction's COMMIT could commit the next caller's work.
  • Fixed: tx.close({ timeout }) committed the transaction when its queries finished in time. It now rolls back.
  • Fixed: the sql.begin() callback saw no AsyncLocalStorage store on a cold pool, and another request's store on a busy one. It now sees its caller's.
  • Fixed: a result returned from a sql.begin() or tx.savepoint() callback was returned as a plain array without count and command.
  • Fixed: sql.begin() and sql.beginDistributed() (PostgreSQL, MySQL) rejected a transaction the server had committed or prepared when the server's response and its shutdown error arrived in the same read.

Bun.SQL queries and results

  • Improved: a sql`...` call compiled by Babel's or swc's loose mode for template literals now throws a TypeError. Disable loose mode for template literals.
  • Fixed: affectedRows was always null on PostgreSQL and SQLite. It's now the number of rows written, and 0 for a SELECT. On MySQL, count was 0 after a write.
  • Fixed: WHERE id IN ${sql([])} was a syntax error on PostgreSQL and MySQL. It now matches no rows.
  • Fixed: on MySQL and MariaDB, sql("db.table") quoted the whole string as a single identifier. It's now `db`.`table`, as documented.
  • Fixed: on MySQL, ON DUPLICATE KEY UPDATE ${sql({ age: 31 })} was a syntax error when the clause was lowercase or split across lines.
  • Fixed: sql instanceof Bun.SQL threw a TypeError.
  • Improved: in TypeScript, the awaited result of a query has count, command, affectedRows, columns and the other result properties.

Bun.SQL connection options

  • Improved: Bun.SQL no longer reads the PASSWORD environment variable. Use PGPASSWORD, MYSQL_PASSWORD, the URL or the password option.
  • Fixed: new SQL({ filename: "./dev.db" }) without adapter tried to connect to PostgreSQL, and SQLITE_URL=x.db tried to open a file named postgres://x.db.
  • Fixed: the documented socket option and ?socket= had no effect, and mysql://user:pass@/db?socket=/path threw Invalid URL.
  • Fixed: a path that doesn't exist was ignored, and Bun connected over TCP. The connection now fails with ERR_POSTGRES_CONNECTION_REFUSED or ERR_MYSQL_CONNECTION_REFUSED.
  • Fixed: PGUSERNAME and USERNAME were documented as the PostgreSQL user but ignored.
  • Fixed: a mariadb:// URL without a database tried to select a database named mariadb and failed with errno 1049. It no longer selects one.
  • Fixed: with the SQLite adapter, ?mode=rw created the file, and ?mode=RO or { readwrite: false } opened it read-write.

Bun.SQL with PostgreSQL

  • Fixed: each distinct combination of NULL parameters prepared another server-side statement, and none of them were ever closed, so backends grew to several GB. Each connection now keeps its 1,024 most recent statements.
  • Fixed: after ALTER TABLE, DEALLOCATE ALL or DISCARD ALL, a cached statement kept failing, for example with cached plan must not change result type. Bun now re-prepares it.
  • Fixed: sql.array(["a", "b"]) without a type was cast to JSON[], so a text[] column stored JSON-quoted elements. Bun no longer adds a cast.
  • Fixed: null in sql.array(values, "TEXT") or "INT" became the string "null" or an error. It's now SQL NULL.
  • Fixed: a prepared query that returned an int4[] or float4[] with a NULL or more than one dimension rejected with ERR_POSTGRES_NULLS_IN_ARRAY_NOT_SUPPORTED_YET or ERR_POSTGRES_MULTIDIMENSIONAL_ARRAY_NOT_SUPPORTED_YET.
  • Fixed: a Date bound to text was stored as its toString(), and a plain object as [object Object]. They're now stored as ISO 8601 and JSON.
  • Fixed: a Date bound to date or time failed.
  • Fixed: with prepare: false, a Date was sent as its toString(), and an object bound to jsonb as [object Object].
  • Fixed: an array or object bound to a bool, int4, float8 or timestamp parameter was coerced on the client, so [] became true. It's now sent as text and validated by the server.
  • Fixed: an Invalid Date was stored as 1970-01-01 on PostgreSQL and MySQL. The query now rejects.
  • Fixed: whole numbers from 2^51 to 2^53 - 1 were sent as float8, so numeric stored 2^52 + 1 as 4503599627370500. They're now sent as int8, which Bun returns as a string.
  • Fixed: COPY ... FROM STDIN and COPY ... TO STDOUT as plain queries, without a stream, now reject with ERR_POSTGRES_COPY_NOT_SUPPORTED.
  • Fixed: PostgresError.column and .line pointed to a position in Bun's own source code when the server didn't send one.

Bun.SQL with MySQL and MariaDB

  • Fixed: queries on the same connection were sent one at a time instead of pipelined, a regression since v1.2.22. With 30 ms of latency, four prepared queries now take 31 ms, down from 124 ms.
  • Fixed: without TLS, caching_sha2_password logins failed with errno 1045 for passwords of 20 bytes or more. Fast authentication also never succeeded, so plain TCP needed allowPublicKeyRetrieval: true on every login.
  • Fixed: sha256_password was documented, but every login failed with errno 1045.
  • Fixed: a single INSERT with K nullable columns could prepare up to 2^K statements on the server, which were never closed. Each connection now keeps its 256 most recent statements.
  • Fixed: a failed prepare was cached, so the query kept failing after a CREATE TABLE had made it valid.
  • Fixed: a query on a connection that had been idle didn't keep the process alive, so the process could exit with code 0 while the query was in flight.
  • Fixed: two queries on one connection could resolve with each other's rows when a parameter's toJSON() started a query.
  • Fixed: MariaDB rejected every object or array parameter with errno 1210.
  • Fixed: a Date before 1970 was rejected, and a prepared query lost the fractional seconds of TIME(n) columns.
  • Fixed: lastInsertRowid above 2^53 - 1 was rounded. It's now returned as a string, or a BigInt with bigint: true.
  • Fixed: Bun.SQL threw ERR_MYSQL_UNSUPPORTED_PROTOCOL_VERSION when a MySQL or MariaDB server rejected a new connection, such as when it hit max_connections. It now throws the server's error (errno 1040, "Too many connections").

Bun.SQL with SQLite

  • Fixed: a valid query resolved to [] when its text was written like select*from t or /*c*/select, or was values (...).
  • Fixed: INSERT ... SELECT and WITH ... INSERT reported a count of 0.
  • Fixed: a string of several statements whose first statement returns rows, such as a migration file that starts with a PRAGMA, only ran that statement.
  • Fixed: a second sql.begin() while another one was running failed with cannot start a transaction within a transaction. It now waits.
  • Fixed: a failed COMMIT left the transaction open, so every later sql.begin() failed.

bun:sqlite

  • Fixed: bun:sqlite lost writes at process exit when iterate() on a statement that writes, like INSERT ... RETURNING, wasn't read to the end. They're now committed. An explicit BEGIN is still rolled back.
  • Fixed: new Database(path, { create: false }) failed with SQLITE_MISUSE on an existing file, and so did {}, { readonly: false } and { readwrite: false }.
  • Fixed: { readonly: true, create: true } opened the database read-write. It now throws a TypeError.
  • Fixed: db.run("a; b; c") and db.exec() swallowed runtime errors in every statement except the last. They now throw at the failing statement and skip the rest.
  • Fixed: after ALTER TABLE, an existing Statement returned rows with the old column names, or values under the wrong keys.
  • Fixed: { name: 1 } bound NULL to $name by default, and { $name: 1 } threw with strict: true. Both forms now work in both modes.
  • Fixed: db.prepare(sql, "x") ignored a binding that wasn't an array.
  • Fixed: row["1"] was undefined for SELECT 1.
  • Fixed: changes also counted rows written by triggers, foreign key cascades and FTS5 shadow tables. It now counts the statement's own rows, like node:sqlite.
  • Fixed: break out of a stmt.iterate() loop left the cursor open, so a later DROP TABLE failed with database table is locked.
  • Fixed: db.transaction(fn) committed when fn returned a promise that was already rejected, which is what an async function does when it throws before its first await. It now rolls back.
  • Fixed: stmt.run(new Date()), or a Map or a Promise as the only argument, bound NULL. It now throws a TypeError.
  • Fixed: terminating a Worker during a write with RETURNING outside a transaction could commit rows without their full-text (FTS) index entries. MATCH then missed those rows. INSERT INTO ft(ft) VALUES('rebuild') repairs the index.
  • Fixed: db.query() cached SQL text of any size, so a loop over multi-megabyte queries with changing text grew to 953 MB. It now peaks at 92 MB.
  • Improved: statements now report their native memory to the garbage collector. 20,000 dropped 200-parameter statements hold 27 MB instead of 465 MB.
  • Improved: stmt.get() stops after the first row instead of reading the whole result. Statements that write (INSERT, UPDATE, DELETE … RETURNING) still apply and commit all their changes.
  • Improved: rows with 63 or more columns take 310 instructions per cell, down from 1,665.
  • Improved: db.run() with a string of 80,000 statements takes 0.11 s of CPU time, down from 5.34 s.
  • Improved: Workers no longer share one SQLite malloc mutex. As a result, PRAGMA hard_heap_limit and soft_heap_limit are accepted but no longer enforced, like in node:sqlite.

node:sqlite

  • Fixed: db.close() kept the file and its -wal and -shm files open until every StatementSync was garbage collected, which caused EBUSY on Windows.
  • Fixed: a Proxy (Vue reactive(), MobX) or a module namespace as named parameters bound NULL to every parameter.
  • Fixed: stmt.get() on INSERT ... RETURNING returned a row even though the commit failed on a deferred foreign key. It now throws.
  • Improved: stmt.get() on a 5-column row takes 4,077 instructions, down from 13,458.

TypeScript types#

  • Fixed: toHaveBeenCalledOnce() was missing from bun-types. The matcher already worked in bun test.
  • Fixed: @types/bun typed Bun.hash() as number | bigint. It always returns a bigint.
  • Fixed: @types/bun accepted a Buffer or URL as the path in Bun.mmap(). Both throw at runtime.
  • Fixed: @types/bun was missing byteLength on instances of Bun.MD5, Bun.SHA256 and the other hash classes.
  • Fixed: @types/bun typed the cpuTime fields of a subprocess's resourceUsage() as number when they're bigint, and FileSystemRouter's origin as string when it can be null.

Thanks to 39 contributors!#