tomori0nao/hono-wasmer-demo
Public
wasmer run tomori0nao/hono-wasmer-demo

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.jsonbun install + bun.lock
node --testbun 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.0WinterCG 标准,addEventListener('fetch') 入口,直接吃 dist/worker.js
wasmer/edgejs@0.2.0Node 兼容层,跑 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。

版本

组件版本
bun1.4.4(见仓库根 README 的安装说明)
hono4.13.14
wasmer cli7.4.0(见仓库根 README 的 glibc 说明)
wasmer/winterjs1.2.0

Bun.build 只做打包,esbuild 依赖已移除;bun.lock 是唯一锁文件(package-lock.json 已删)。

Hono template running on WinterJS / WASIX (Wasmer Edge)