跳到正文

Blog

我们用 Bun 做 Appaloft:一次不太营销的工程复盘

一篇工程复盘:Appaloft 如何用 Bun 做 TypeScript 脚本、编译二进制、嵌入 Web/docs 静态资源、PGlite runtime,以及多平台发布打包。

AppaloftBun自部署工程复盘

Appaloft 是一个 open-source deployment control plane。它面向的是那些希望从本地项目、Git 工作流和自动化工具部署应用,同时仍然能在出问题时理解基础设施状态的开发者。

我们选择 Bun,不是因为“用 Bun 写的”这句话适合放在 README 徽章旁边。需求本身更朴素:Appaloft 应该容易在本地跑起来。理想体验是,一个 CLI 或 binary 就能启动 backend、提供 Web console、提供 public docs,并默认使用本地 PGlite 数据库。

这个目标让 Bun 开始变得有吸引力。Appaloft 是 TypeScript monorepo,但它不只是一个库,也不只是一个 Web app。它同时包含 CLI、backend server、静态 Web console、public docs、release tooling、smoke tests、Docker image 和 binary artifacts。

Bun 让这种发布形态少了很多额外胶水。它没有替我们做完 runtime 设计。

为什么会选 Bun

公共 Appaloft 仓库直接 pin 了 Bun:


{
  "packageManager": "bun@1.3.14",
  "engines": {
    "bun": ">=1.3.14"
  }
}

同一个仓库也用 Bun 处理日常脚本、smoke path 和 release packaging:

{
  "serve": "bun run --cwd apps/shell src/index.ts serve",
  "package:binary-bundle": "bun run scripts/release/build-binary-bundle.ts",
  "package:deploy-cli-bundle": "bun run scripts/release/build-deploy-cli-bundle.ts",
  "package:artifacts": "bun run scripts/release/build-artifacts.ts"
}

CLI package 的构建路径也很直接:

{
  "bin": {
    "appaloft": "./src/index.ts"
  },
  "scripts": {
    "build": "bun build src/index.ts --outfile dist/appaloft --target bun"
  }
}

这不是在说每个项目都应该切到同一个 runtime。对我们来说,价值更具体:release scripts、tests、源码 CLI 执行和 compiled output 可以留在同一套偏 TypeScript 的工作流里。

最有用的 Bun 能力主要是这些:

  • 直接执行 TypeScript,用来写脚本和本地跑 CLI
  • bun build --compile 做接近 single-binary 的分发
  • with { type: "file" } 导入文件
  • 在 release tooling 里使用 Bun.fileBun.writeBun.resolveSync
  • monorepo 里统一使用 bun runbun test

速度当然不错。但更大的收益是,packaging 不再像另一个独立的小项目。

核心设计:把前端嵌进 binary

最 Bun-specific 的 release 代码在 scripts/release/lib/binary-bundle.ts。Binary bundle pipeline 是刻意写得很显式的:

  1. 构建 Web console。
  2. 构建 public docs。
  3. 扫描静态产物目录。
  4. 生成 embedded-web-assets.generated.ts
  5. 生成 embedded-docs-assets.generated.ts
  6. 对每个文件使用 with { type: "file" } 导入。
  7. 把这些 import 转成以 route 为 key 的 Blob map。
  8. 生成临时 binary-entry.ts
  9. bun build --compile 编译这个 entry。

生成出来的 asset module 大概长这样:

import asset0 from "./build/index.html" with { type: "file" };
import asset1 from "./build/_app/entry.js" with { type: "file" };

export const embeddedWebAssets = {
  "/index.html": Bun.file(asset0),
  "/_app/entry.js": Bun.file(asset1),
} as const satisfies Readonly<Record<string, Blob>>;

真实 release script 会对 Web build 和 docs build 里的每个文件都做这件事。临时 binary entry 再导入:

  • embeddedWebAssets
  • embeddedDocsAssets
  • pglite.data
  • pglite.wasm
  • initdb.wasm
  • 真正的 shell runner

最后 release script 编译生成的 entry:

bun build dist/.tmp-binary-bundle/binary-entry.ts \
  --compile \
  --target=<release-target> \
  --outfile <out-dir>/appaloft

这是 Bun 让人感觉“刚好合适”的地方。File imports 让我们可以把静态 UI 和 docs tree 直接变成 runtime data,不需要先发明一套 asset-pack 格式。

但这里也是 demo 版本结束的地方。--compile 会给你一个 binary。它不会替产品回答 /docs 应该是什么意思、clean URL 应该怎么解析、什么时候应该走 SPA fallback,或者 operator 如何覆盖 embedded files。

PGlite 让本地启动简单了不少

Appaloft 默认使用 PGlite,除非配置明确指向 Postgres。公共配置会从显式 flag、环境变量、配置文件,或者 APPALOFT_DATABASE_URL 的存在来解析 database driver。

所以低门槛的本地路径可以是:

appaloft serve

而外部数据库路径仍然可以是:

APPALOFT_DATABASE_DRIVER=postgres
APPALOFT_DATABASE_URL=postgres://...
appaloft serve

Binary bundle 会嵌入 PGlite runtime files:

pglite.data
pglite.wasm
initdb.wasm

运行时,生成的 entry 只会在当前 database driver 是 PGlite 时加载这些 embedded PGlite assets。在 persistence layer 里,PGlite 和 Postgres 通过同一个 database creation boundary 进入系统:PGlite 创建 embedded Kysely dialect,Postgres 则要求提供 database URL。

这里我们仍然很谨慎。PGlite 降低了试用 Appaloft 和运行本地控制面的成本。但它不能让生产路径变成后补的东西。本地默认路径和 Postgres 路径必须并存,而不是互相替代。

运行时边界:embedded assets vs filesystem assets

Single binary 会嵌入 Web 和 docs assets。Docker 不这样做。

公共 Dockerfile 会在 builder stage 构建 shell、Web console、docs 和 PGlite runtime assets。Runtime image 则把静态产物复制到普通目录:

COPY --from=builder /app/apps/web/build /app/web
COPY --from=builder /app/apps/docs/dist /app/docs

ENV APPALOFT_WEB_STATIC_DIR=/app/web
ENV APPALOFT_DOCS_STATIC_DIR=/app/docs

这个差异是刻意的。对 binary archive 来说,embedded assets 能让安装更简单。对 Docker image 来说,普通文件更容易 inspect、分层、替换和缓存。

Server 同时支持两条路径。createAppaloftServer 可以接收 embedded Web/docs assets,也可以从配置或本地 build output 解析静态目录。HTTP adapter 再从对应来源提供静态资源。

Adapter 需要处理几类情况:

  • 直接匹配静态文件
  • 匹配 route 级别的 index.html
  • 匹配 clean URL 的 .html
  • Web console 的 SPA fallback
  • /docs 下的 docs path
  • embedded Blob assets
  • filesystem assets
  • filesystem 读取时的 path traversal protection

Web console 使用 fallback-to-root,因为它是 SPA。Docs 不使用同样的 fallback。这个差异在代码里不大,但一旦错了,用户马上能看出来。

Asset map 是容易的部分。真正费工夫的是让 /docs、clean URLs、SPA fallback、filesystem overrides 和 embedded Blob responses 行为一致。

用下来顺手的部分

Bun scripting 让 release pipeline 更容易留在 TypeScript 里。Bundle script 会删除目录、运行构建、扫描文件、生成 module、解析 package assets、写 launcher、编译最终 binary,中间不需要切到另一种脚本语言。

bun build --compile 让 CLI/server 分发更直接。我们不需要为了 release format 去扭曲应用结构,而是生成一个 release-specific entry module,然后编译它。

File imports 让 asset embedding 很自然。生成的模块好在足够无聊:import file,用 Bun.file 包起来,导出一个 Record<string, Blob>

Multi-target model 也变得更显式。Appaloft 为 macOS、Linux 和 Windows 建模 release targets,也区分 Linux glibc 和 musl。每个 target 都带着 Bun target、npm binary package name、executable name、archive format 和相关 platform metadata。

Monorepo workflow 也比较统一。bun runbun testbun build 和 TypeScript release scripts 都在同一套日常词汇里。这听起来普通,但 release script 已经做了很多事。普通反而是优点。

几个没预料到的细节

--compile 不会替你设计 serving contract。你仍然要决定 assets 从哪里来、如何寻址、URL 没有对应 literal file 时应该发生什么。

当前端由 backend serve 时,前端路由细节会变成 backend responsibility。Appaloft 必须区分 Web console 的 SPA fallback 和 docs path handling,也必须支持 clean URLs 和 route-level index.html

macOS signing 需要显式处理。这个点很容易漏掉:--compile 会修改 Bun 已签名的 executable,所以 release script 会移除 stale signature,然后对生成的 Darwin executable 做 ad-hoc signing。

Docker 和 binary bundle 需要不同答案。Binary distribution 适合 embedded assets。Docker image 更适合 /app/web/app/docs

Embedded assets 很方便,但不能成为唯一通道。Appaloft 保留了 APPALOFT_WEB_STATIC_DIRAPPALOFT_DOCS_STATIC_DIR 作为 overrides,避免 operator 和 packager 被锁死在 binary layout 里。

还没磨平的地方

Binary size 和 build time 需要持续量化。把 Web console、docs 和 PGlite runtime assets 都嵌进去很方便,但不是免费的。正式长期记录里,我们应该补上每个 target 的 binary size 和 release build timing。

Cross-platform targets 是实打实的工作。macOS、Linux glibc、Linux musl 和 Windows 在 executable naming、archive format、runtime assumptions、signing 和 downstream package mapping 上都有差异。

Compiled binary debugging 和源码开发不是同一种体验。开发时直接跑 TypeScript 很舒服。调试一个带 generated entrypoint 和 embedded assets 的 binary,需要另一套心智模型。

嵌入 UI 和 docs 意味着更新 UI/docs 就要重新发 binary。对 self-contained archive 来说这是好事,但它不总是最佳运维模型。Docker filesystem strategy 存在,就是因为不同分发格式应该有不同答案。

PGlite 很适合作为 local-first default,但 Appaloft 仍然必须把 Postgres 路径保持强壮。Deployment control plane 应该让第一次运行很容易,同时不能让团队和生产路径像后补上去的。

结尾

对 Appaloft 来说,Bun 最大的价值不是速度本身,而是它让这个发布形态变得可操作:

TypeScript app
+ CLI
+ backend server
+ static Web console
+ static docs
+ embedded PGlite runtime assets
+ optional compiled binary

这是一个很具体的形态,而 Bun 刚好适合它。

问题在于,Bun 给的是有用的 primitives,不是产品架构。我们仍然要自己决定 embedded assets 和 filesystem assets 的边界、PGlite 和 Postgres 的边界、为什么 Docker 和 binary packaging 不应该共享同一种 asset strategy,以及源码开发体验在哪里开始和 compiled release 不一样。

如果你也在发布 compiled Bun CLI 或 local-first server,我们很想交流经验。尤其是 asset embedding、大型静态 bundle、glibc/musl target strategy,以及 release pipeline 变复杂以后怎么调试 compiled binary。