hono-wasmer-demo
Hono 模板:同样的代码在 Bun 上调试,打包成 worker 后跑在 Wasmer 的 WinterJS(WASIX)里。
快速开始
bun install
bun run dev # 本机预览 http://127.0.0.1:8787,改代码自动重启
bun test # 路由契约 + WinterJS 兼容补丁回归
bun run build # 产出 dist/worker.js(单文件,44 KB)
bun run wasmer # build 后用 wasmer 跑真 runtime
bun run wasmer 跑的是:wasmer run --net --volume .:/app wasmer/winterjs@1.2.0 -- serve -p 8080 --ip 0.0.0.0 /app/dist/worker.js。
为什么用 Bun
不是「换个人人都爱的包管理器」,是它把三件事收进了一个二进制:
| 原本用的 | 现在 |
|---|---|
npm install + package-lock.json | bun install + bun.lock |
node --test | bun test(原生 Jest 风格 API) |
esbuild(额外一个依赖树) | Bun.build |
node:http 手搓 worker 适配层 | Bun.serve 直接吃标准 Request/Response |
结果:依赖只剩 hono 一个,bun.lock 15 行,测试 12ms 跑完。
目录
src/app.js 路由:请求解析 + 响应
src/worker.js WinterCG 入口,注册 fetch 事件
src/winterjs-compat.js WinterJS 的 Response 兼容补丁(关键)
scripts/build.mjs Bun.build 打包成单文件 worker
scripts/serve.mjs Bun.serve 模拟 worker 环境
scripts/dev.mjs fs.watch + 重启
test/ bun test
请求解析 / 响应
Hono 的 c.req 在 edge runtime 上就是标准 Request 的封装,直接可用:
app.get('/echo/:name', (c) =>
c.json({
method: c.req.method, // GET
path: c.req.path, // /echo/你好
name: c.req.param('name'), // 你好(已自动解码)
q: c.req.query('q') ?? null, // query 参数
ua: c.req.header('user-agent'),
}),
)
app.post('/echo', async (c) => {
const contentType = c.req.header('content-type') ?? ''
const body = contentType.includes('application/json')
? await c.req.json()
: await c.req.text()
return c.json({ contentType, body }, 201)
})
响应侧按需选 c.text / c.json / c.html / c.body,第二个参数可以给状态码或 Headers。
踩到的坑:c.json() 在 WinterJS 上必然 500
现象:c.text() 正常,但只要走 c.json() 就返回 500 Internal Server Error,日志只有一行:
RangeError at .../worker.js:557:46 - Invalid response status code
根因:Hono 组装响应的代码是
const status = typeof arg === 'number' ? arg : arg?.status ?? this.#status
return new Response(data, { status, headers })
#status 只有在显式调用过 c.status() 之后才有值,默认是 undefined。
于是 init 实际是 { status: undefined, headers }。
Node / Workers / Deno / Bun 都把 undefined 当默认的 200,WinterJS 直接抛 RangeError。
Hono 内置的 404 处理器也受影响,它用的是 new Response()(body 为 null),WinterJS 同样拒收。
修法:只在 runtime 边界补一层,业务代码保持纯 Hono 写法。
// src/winterjs-compat.js
globalThis.Response = new Proxy(Response, {
construct: (target, [body, init], newTarget) => {
if (init && typeof init === 'object' && 'status' in init && typeof init.status !== 'number') {
return Reflect.construct(target, [body, { ...init, status: 200 }], newTarget)
}
return Reflect.construct(target, [body, init], newTarget)
},
})
配了幂等标记,重复 import 不会套娃。test/winterjs-compat.test.js 用一个「严格版 Response」模拟 WinterJS,把这条回归钉住了。
打包必须 --target=browser
Bun.build 默认会按 Bun 运行时选 target:
target: 'bun'→ 产物顶部多一行// @bun,node:*解析成 Bun 内建,WASIX 里跑不了target: 'node'→ 同上,产物为 Node 定制target: 'browser'→ 纯粹的 WinterCG/Web 标准,WinterJS 才认
所以 scripts/build.mjs 里钉死 target: 'browser'。
watch 为什么自己写
Bun.build 的 JS API 没有 watch(bun build --watch 只有 CLI 有,而且只重编译、不重启进程)。
scripts/dev.mjs 用 node:fs 的 watch 监听 src/,构建成功后再 kill 旧进程、spawn 新的,
保证「不会拿旧产物重启」。
为什么是 WinterJS
Wasmer 的 JS 运行时有两个选择:
| 运行时 | 情况 |
|---|---|
wasmer/winterjs@1.2.0 | WinterCG 标准,addEventListener('fetch') 入口,直接吃 dist/worker.js |
wasmer/edgejs@0.2.0 | Node 兼容层,跑 Hono 得走 node:http + @hono/node-server,还要开 --experimental-napi |
模板用 WinterJS:和 Cloudflare Workers 同一套心智模型,同一个 app.fetch 能同时跑 Workers、Deno、WinterJS。
两个必须注意的点:
wasmer run默认没有网络权限,启动服务要加--net,否则报error binding to 0.0.0.0:8080: Not supported (os error 58);- 用
--volume .:/app把产物挂进去(--dir已废弃),入口写容器内路径/app/dist/worker.js。
版本
| 组件 | 版本 |
|---|---|
| bun | 1.4.4(见仓库根 README 的安装说明) |
| hono | 4.13.14 |
| wasmer cli | 7.4.0(见仓库根 README 的 glibc 说明) |
| wasmer/winterjs | 1.2.0 |
Bun.build 只做打包,esbuild 依赖已移除;bun.lock 是唯一锁文件(package-lock.json 已删)。