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.file、Bun.write和Bun.resolveSync - monorepo 里统一使用
bun run和bun test
速度当然不错。但更大的收益是,packaging 不再像另一个独立的小项目。
核心设计:把前端嵌进 binary
最 Bun-specific 的 release 代码在 scripts/release/lib/binary-bundle.ts。Binary bundle pipeline 是刻意写得很显式的:
- 构建 Web console。
- 构建 public docs。
- 扫描静态产物目录。
- 生成
embedded-web-assets.generated.ts。 - 生成
embedded-docs-assets.generated.ts。 - 对每个文件使用
with { type: "file" }导入。 - 把这些 import 转成以 route 为 key 的
Blobmap。 - 生成临时
binary-entry.ts。 - 用
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 再导入:
embeddedWebAssetsembeddedDocsAssetspglite.datapglite.wasminitdb.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
Blobassets - 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 run、bun test、bun 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_DIR 和 APPALOFT_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。