[博客翻译]Bun 1.2发布


原文地址:https://bun.sh/blog/bun-v1.2


Bun 1.2 发布

Bun 是一个用于构建和测试全栈 JavaScript 和 TypeScript 应用的完整工具包。如果你还不熟悉 Bun,可以通过 Bun 1.0 的博客文章了解更多。

Bun 1.2 更新亮点

Bun 1.2 是一个重大更新,我们非常兴奋地与大家分享。以下是 Bun 1.2 的主要变化:

  • Node.js 兼容性:Bun 在 Node.js 兼容性方面取得了重大进展。
  • 内置 S3 对象存储 API:新增了 Bun.s3,支持与 S3 存储的交互。
  • 内置 Postgres 客户端:新增了 Bun.sql,支持 Postgres 数据库操作(MySQL 支持即将推出)。
  • bun install 使用基于文本的锁文件bun.lock 取代了之前的二进制锁文件。

此外,Bun 1.2 还让 Express 框架的性能提升了 3 倍。

Node.js 兼容性

Bun 旨在成为 Node.js 的直接替代品。在 Bun 1.2 中,我们开始为每次 Bun 的更改运行 Node.js 测试套件。自那时起,我们已经修复了数千个错误,以下 Node.js 模块在 Bun 中的测试通过率超过了 90%。

如何衡量兼容性?

在 Bun 1.2 中,我们改变了测试和改进 Bun 与 Node.js 兼容性的方式。以前,我们根据用户报告的 GitHub 问题来优先修复 Node.js 的 bug。虽然这解决了用户遇到的实际问题,但这种方式过于被动,难以实现 100% 的 Node.js 兼容性。

于是我们想到:为什么不直接运行 Node.js 的测试套件呢?

在 Bun 中运行 Node.js 测试

Node.js 的测试套件包含数千个测试文件,大部分位于 test/parallel 目录中。虽然“直接运行”这些测试听起来很简单,但实际上比想象中复杂得多。

内部 API

许多测试依赖于 Node.js 的内部实现细节。例如,以下测试中,getnameinfo 被模拟为总是出错,以测试 dns.lookupService() 的错误处理。

const { internalBinding } = require("internal/test/binding");
const cares = internalBinding("cares_wrap");
const { UV_ENOENT } = internalBinding("uv");
cares.getnameinfo = () => UV_ENOENT;

为了在 Bun 中运行这个测试,我们不得不用自己的模拟替换内部绑定。

Bun.dns.lookupService = (addr, port) => {
  const error = new Error(`getnameinfo ENOENT ${addr}`);
  error.code = "ENOENT";
  error.syscall = "getnameinfo";
  throw error;
};

错误信息

还有一些 Node.js 测试会检查错误信息的精确字符串。虽然 Node.js 通常不会更改错误信息,但它们并不保证在版本之间不会发生变化。

const common = require("../common");
const assert = require("assert");
const cp = require("child_process");
assert.throws(
  () => {
    cp.spawnSync(process.execPath, [__filename, "child"], { argv0: [] });
  },
  {
    code: "ERR_INVALID_ARG_TYPE",
    name: "TypeError",
    message: 'The "options.argv0" property must be of type string.' + common.invalidArgTypeHelper([]),
  }
);

为了解决这个问题,我们不得不修改一些测试的断言逻辑,改为检查 namecode,而不是 message

{
  code: "ERR_INVALID_ARG_TYPE",
  name: "TypeError",
  message: 'The "options.argv0" property must be of type string.' + common.invalidArgTypeHelper([]),
}

虽然我们尽量匹配 Node.js 的错误信息,但有时我们也会提供更有帮助的错误信息,只要 namecode 相同即可。

目前的进展

我们已经将数千个 Node.js 测试文件移植到 Bun 中。这意味着每次我们对 Bun 进行更改时,都会运行 Node.js 测试套件以确保兼容性。

每天,我们都在为 Bun 添加越来越多的 Node.js 测试通过率,我们很快会分享更多关于 Node.js 兼容性的进展。

除了修复现有的 Node.js API,我们还添加了对以下 Node.js 模块的支持。

node:http2 服务器

你现在可以使用 node:http2 来创建 HTTP/2 服务器。HTTP/2 也是 gRPC 服务器的必要条件,Bun 现在也支持 gRPC 服务器。之前,Bun 只支持 HTTP/2 客户端。

import { createSecureServer } from "node:http2";
import { readFileSync } from "node:fs";
const server = createSecureServer({
  key: readFileSync("key.pem"),
  cert: readFileSync("cert.pem"),
});
server.on("stream", (stream, headers) => {
  stream.respond({
    ":status": 200,
    "content-type": "text/html; charset=utf-8",
  });
  stream.end("<h1>Hello from Bun!</h1>");
});
server.listen(3000);

在 Bun 1.2 中,HTTP/2 服务器的性能比 Node.js 快 2 倍。当我们为 Bun 添加新 API 时,我们会花很多时间优化性能,确保它不仅能用,而且更快。

node:dgram

你现在可以使用 node:dgram 绑定和连接到 UDP 套接字。UDP 是一种低级别的不可靠消息协议,通常用于遥测提供商和游戏引擎。

import { createSocket } from "node:dgram";
const server = createSocket("udp4");
const client = createSocket("udp4");
server.on("listening", () => {
  const { port, address } = server.address();
  for (let i = 0; i < 10; i++) {
    client.send(`data ${i}`, port, address);
  }
  server.unref();
});
server.on("message", (data, { address, port }) => {
  console.log(`Received: data=${data} source=${address}:${port}`);
  client.unref();
});
server.bind();

这使得像 DataDog 的 dd-trace@clickhouse/client 这样的包可以在 Bun 1.2 中正常工作。

node:cluster

你可以使用 node:cluster 来生成多个 Bun 实例。这通常用于通过跨多个 CPU 核心运行任务来提高吞吐量。

以下是一个使用 cluster 创建多线程 HTTP 服务器的示例:

  • 主工作进程生成 n 个子工作进程(通常等于 CPU 核心数)。
  • 每个子工作进程监听相同的端口(使用 reusePort)。
  • 传入的 HTTP 请求在子工作进程之间进行负载均衡。
import cluster from "node:cluster";
import { createServer } from "node:http";
import { cpus } from "node:os";
if (cluster.isPrimary) {
  console.log(`Primary ${process.pid} is running`);
  // 启动与 CPU 数量相等的工作进程
  for (let i = 0; i < cpus().length; i++) {
    cluster.fork();
  }
  cluster.on("exit", (worker, code, signal) => {
    console.log(`Worker ${worker.process.pid} exited`);
  });
} else {
  // 传入的请求由工作进程池处理,而不是主工作进程
  createServer((req, res) => {
    res.writeHead(200);
    res.end(`Hello from worker ${process.pid}`);
  }).listen(3000);
  console.log(`Worker ${process.pid} started`);
}

注意,reusePort 仅在 Linux 上有效。在 Windows 和 macOS 上,操作系统不会像预期的那样对 HTTP 连接进行负载均衡。

node:zlib

在 Bun 1.2 中,我们重写了整个 node:zlib 模块,从 JavaScript 改为原生代码。这不仅修复了许多 bug,还使其性能比 Bun 1.1 快 2 倍。

我们还为 node:zlib 添加了对 Brotli 的支持,这在 Bun 1.1 中是缺失的。

import { brotliCompressSync, brotliDecompressSync } from "node:zlib";
const compressed = brotliCompressSync("Hello, world!");
compressed.toString("hex"); // "0b068048656c6c6f2c20776f726c642103"
const decompressed = brotliDecompressSync(compressed);
decompressed.toString("utf8"); // "Hello, world!"

使用 V8 API 的 C++ 插件

如果你想在 JavaScript 代码中使用 C++ 插件,最简单的方法是使用 N-API

然而,在 N-API 出现之前,一些包使用了 Node.js 内部的 V8 C++ API。这使得事情变得复杂,因为 Node.js 和 Bun 使用不同的 JavaScript 引擎:Node.js 使用 V8(Chrome 使用的引擎),而 Bun 使用 JavaScriptCore(Safari 使用的引擎)。

以前,像 cpu-features 这样依赖这些 V8 API 的 npm 包在 Bun 中无法工作。

require("cpu-features")();
dyld[94465]: missing symbol called
fish: Job 1, 'bun index.ts' terminated by signal SIGABRT (Abort)

为了解决这个问题,我们进行了前所未有的工程努力,在 JavaScriptCore 中实现了 V8 的公共 C++ API,使得这些包可以在 Bun 中“直接运行”。这个实现非常复杂,我们甚至写了一篇 三部分的博客 来解释我们是如何在不使用 V8 的情况下支持 V8 API 的。

在 Bun 1.2 中,像 cpu-features 这样的包可以直接导入并运行。

$ bun index.ts
{
  arch: "aarch64",
  flags: {
    fp: true,
    asimd: true,
    // ...
  },
}

V8 C++ API 的支持非常复杂,因此大多数包仍然会有一些缺失的功能。我们将继续改进支持,以便像 node-canvas@v2node-sqlite3 这样的包在未来能够正常工作。

node:v8

除了 V8 C++ API,我们还添加了对使用 node:v8 进行堆快照的支持。

import { writeHeapSnapshot } from "node:v8";
// 将堆快照写入当前工作目录,文件名为 `Heap-{date}-{pid}.heapsnapshot`
writeHeapSnapshot();

在 Bun 1.2 中,你可以使用 getHeapSnapshotwriteHeapSnapshot 来读取和写入 V8 堆快照。这使得你可以使用 Chrome DevTools 来检查 Bun 的堆。

Express 性能提升 3 倍

虽然兼容性对于修复 bug 很重要,但它也帮助我们修复了 Bun 中的性能问题。

在 Bun 1.2 中,流行的 express 框架处理 HTTP 请求的速度比 Node.js 快 3 倍。这是通过改进与 node:http 的兼容性以及优化 Bun 的 HTTP 服务器实现的。

S3 支持与 Bun.s3

Bun 旨在成为一个面向云的 JavaScript 运行时。这意味着它支持你在云中运行生产应用所需的所有工具和服务。

现代应用通常将文件存储在对象存储中,而不是本地的 POSIX 文件系统。当终端用户将文件附件上传到网站时,文件不会存储在服务器的本地磁盘上,而是存储在 S3 存储桶中。将存储与计算解耦可以避免一系列可靠性问题:磁盘空间不足、繁忙的 I/O 导致的高 p95 响应时间,以及共享文件存储的安全问题。

S3 是云中对象存储的事实标准。S3 API 由多种云服务实现,包括 Amazon S3、Google Cloud Storage、Cloudflare R2 等。

这就是为什么 Bun 1.2 添加了对 S3 的内置支持。你可以使用与 Web 标准兼容的 API(如 Blob)从 S3 存储桶中读取、写入和删除文件。

从 S3 读取文件

你可以使用新的 Bun.s3 API 来访问默认的 S3Client。客户端提供了一个 file() 方法,返回对 S3 文件的懒引用,这与 Bun 的 File API 相同。

import { s3 } from "bun";
const file = s3.file("folder/my-file.txt");
// file instanceof Blob
const content = await file.text();
// 或者:
//  file.json()
//  file.arrayBuffer()
//  file.stream()

比 Node.js 快 5 倍

Bun 的 S3 客户端是用原生代码编写的,而不是 JavaScript。与使用 @aws-sdk/client-s3 的 Node.js 相比,Bun 从 S3 存储桶下载文件的速度快 5 倍。

将文件写入 S3

你可以使用 write() 方法将文件上传到 S3。非常简单:

import { s3 } from "bun";
const file = s3.file("folder/my-file.txt");
await file.write("hello s3!");
// 或者:
//  file.write(new Uint8Array([1, 2, 3]));
//  file.write(new Blob(["hello s3!"]));
//  file.write(new Response("hello s3!"));

对于较大的文件,你可以使用 writer() 方法获取一个文件写入器,进行多部分上传,这样你就不必担心细节。

import { s3 } from "bun";
const file = s3.file("folder/my-file.txt");
const writer = file.writer();
for (let i = 0; i < 1000; i++) {
  writer.write(String(i).repeat(1024));
}
await writer.end();

预签名 URL

当你的生产服务需要让用户上传文件到服务器时,通常更可靠的做法是让用户直接上传到 S3,而不是通过服务器作为中介。

为了实现这一点,你可以使用 presign() 方法生成文件的预签名 URL。这会生成一个带有签名的 URL,允许用户安全地上传特定文件到 S3,而不会暴露你的凭证或授予他们不必要的访问权限。

import { s3 } from "bun";
const url = s3.presign("folder/my-file.txt", {
  expiresIn: 3600, // 1 小时
  acl: "public-read",
});

使用 Bun.serve()

由于 Bun 的 S3 API 扩展了 File API,你可以使用 Bun.serve() 通过 HTTP 提供 S3 文件。

import { serve, s3 } from "bun";
serve({
  port: 3000,
  async fetch(request) {
    const { url } = request;
    const { pathname } = new URL(url);
    // ...
    if (pathname === "/favicon.ico") {
      const file = s3.file("assets/favicon.ico");
      return new Response(file);
    }
    // ...
  },
});

当你使用 new Response(s3.file(...)) 时,Bun 不会将 S3 文件下载到服务器并发送给用户,而是将用户重定向到 S3 文件的预签名 URL。

Response (0 KB) {
  status: 302,
  headers: Headers {
    "location": "https://s3.amazonaws.com/my-bucket/assets/favicon.ico?...",
  },
  redirected: true,
}

这节省了内存、时间和下载文件到服务器的带宽成本。

使用 Bun.file()

如果你想使用与本地文件系统相同的代码访问 S3 文件,可以使用 s3:// URL 协议引用它们。这与使用 file:// 引用本地文件的概念相同。

import { file } from "bun";
async function createFile(url, content) {
  const fileObject = file(url);
  if (await fileObject.exists()) {
    return;
  }
  await fileObject.write(content);
}
await createFile("s3://folder/my-file.txt", "hello s3!");
await createFile("file://folder/my-file.txt", "hello posix!");

使用 fetch()

你甚至可以使用 fetch() 从 S3 读取、写入和删除文件。

// 上传到 S3
await fetch("s3://folder/my-file.txt", {
  method: "PUT",
  body: "hello s3!",
});
// 从 S3 下载
const response = await fetch("s3://folder/my-file.txt");
const content = await response.text(); // "hello s3!"
// 从 S3 删除
await fetch("s3://folder/my-file.txt", {
  method: "DELETE",
});

使用 S3Client

当你导入 Bun.s3 时,它会返回一个默认客户端,该客户端使用已知的环境变量(如 AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY)进行配置。

import { s3, S3Client } from "bun";
// s3 instanceof S3Client

你也可以创建自己的 S3Client,然后将其设置为默认客户端。

import { S3Client } from "bun";
const client = new S3Client({
  accessKeyId: "my-access-key-id",
  secretAccessKey: "my-secret-access-key",
  region: "auto",
  endpoint: "https://<account-id>.r2.cloudflarestorage.com",
  bucket: "my-bucket",
});
// 将默认客户端设置为你的自定义客户端
Bun.s3 = client;

Postgres 支持与 Bun.sql

与对象存储一样,生产应用通常需要的另一个数据存储是 SQL 数据库。

从一开始,Bun 就内置了 SQLite 客户端。SQLi