❄️

BunとHonoでActivityPub実装を作ったので紹介とかします。Denoとか

2022/10/30に公開

Intro

その名もMatchbox。

https://gitlab.com/acefed/matchbox

Bunを使いました。

https://bun.sh/

Hono[炎]っていうイケてる名前のフレームワークを使って作りました。

https://hono.dev/

どこかで見たことある流れですね。

https://zenn.dev/tkithrta/articles/a33c27d7f895b5

そうです。以前Denoで作ったものをフォークして少しコードを変えただけです。
別にDenoからBunに切り替えたわけではなく、MatchboxはDenoのままです。

Bun 0.2.2でWeb Crypto APIが実装されたと聞いたので、Hono使っているし多分動かせるだろうと思いやってみました。

https://bun.sh/blog/bun-v0.2.2

Bun

Mastodonでもちゃんと動くことを確認済みです。
ではどのようにマイグレーションすればBunで動かせるのか紹介したいと思います。

  • Matchbox 0.3.0
    • Bun 0.2.2
    • Deno 1.27.0
    • Node.js 18.12.0
    • Hono 2.3.2
    • TypeScript 4.8.3, TypeScript 4.8.4

Hono

まずはMatchbox 0.3.0のソースコードをダウンロードするなりGitからクローンするなりGitpodボタンクリックするなりして用意します。

--- deno-index.ts       2022-10-30 09:50:00.000000000 +0000
+++ bun-index.ts        2022-10-30 09:50:00.000000000 +0000
@@ -1,17 +1,13 @@
-import "dotenv";
-import { serve } from "server";
 import { Hono } from "hono";
 import { basicAuth } from "hono/basic-auth";
-import { serveStatic } from "hono/serve-static";
+import { serveStatic } from "hono/serve-static.bun";
 
 const ENV = {
-  HOST: Deno.env.get("HOST"),
-  PORT: Deno.env.get("PORT"),
-  ENABLE_BASIC_AUTH: Deno.env.get("ENABLE_BASIC_AUTH"),
-  BASIC_AUTH_USERNAME: Deno.env.get("BASIC_AUTH_USERNAME"),
-  BASIC_AUTH_PASSWORD: Deno.env.get("BASIC_AUTH_PASSWORD"),
-  SECRET: Deno.env.get("SECRET"),
-  PRIVATE_KEY: Deno.env.get("PRIVATE_KEY"),
+  PORT: process.env.PORT,
+  ENABLE_BASIC_AUTH: process.env.ENABLE_BASIC_AUTH,
+  BASIC_AUTH_USERNAME: process.env.BASIC_AUTH_USERNAME,
+  BASIC_AUTH_PASSWORD: process.env.BASIC_AUTH_PASSWORD,
+  SECRET: process.env.SECRET,
+  PRIVATE_KEY: process.env.PRIVATE_KEY,
 } as { [key: string]: string };
 
 const app = new Hono();
@@ -616,7 +612,7 @@
   return c.redirect(`/u/${c.req.param("strRoot").slice(1)}`);
 });
 
-serve(app.fetch, {
-  hostname: ENV.HOST || "localhost",
+export default {
   port: Number(ENV.PORT) || 8000,
-});
+  fetch: app.fetch,
+};

こう修正すれば動きます。
実際に変えた部分を解説していきたいと思います。

ESM

import { Hono } from "hono";
import { basicAuth } from "hono/basic-auth";
import { serveStatic } from "hono/serve-static.bun";

Denoではdotenv, serveモジュールをESMとして読み込んでいましたがBunではランタイムに組み込まれているのでインポートする必要ありません。削りましょう。
またHonoのserve-staticをインポートする際はBun専用のミドルウェアとして読み込む必要があるので"hono/serve-static.bun"と書くことに注意が必要です。

ちなみにMatchbox自体BunやNode.jsと互換性を取れるようDenoのimport_map.jsonを使うよう改良しました。

env

const ENV = {
  PORT: process.env.PORT,
  ENABLE_BASIC_AUTH: process.env.ENABLE_BASIC_AUTH,
  BASIC_AUTH_USERNAME: process.env.BASIC_AUTH_USERNAME,
  BASIC_AUTH_PASSWORD: process.env.BASIC_AUTH_PASSWORD,
  SECRET: process.env.SECRET,
  PRIVATE_KEY: process.env.PRIVATE_KEY,
} as { [key: string]: string };

DenoではDeno.env.get("PORT")を使い環境変数を読み込んでいましたがBunはNode.jsと同様にprocess.env.PORTと書いて読み込みます。
ちなみにBun.env.PORTと書いても大丈夫です。

地味にこれが結構難しくて、環境変数はあらかじめ定数として定義しておかないとランタイムごとに一つ一つ該当箇所を書き換えないといけない問題が発生します。
Honoでは環境変数を読み込むAPIのc.env.PORTを用意していますがそれはcontextオブジェクトを使用できるハンドラ内に限った話なので、グローバル変数として使いたいときはこのようにまとめて最初に定義しておくことで複数のランタイム対応が楽になるため今回そのようにしました。

なお、この環境変数API問題はWinterCGでも議論されています。

https://github.com/wintercg/environment-metadata/issues/1

Serve

export default {
  port: Number(ENV.PORT) || 8000,
  fetch: app.fetch,
};

先程のenvでは説明を飛ばしましたが環境変数HOSTを削りました。
というのも現時点でBunのServeはhostnameの引数が用意されていないのです。
Node.jsやDenoをはじめとしたHTTPサーバーではhostnameの引数が用意されているため意外でした。

こちらもDenoでserve()やUnstableなDeno.serve()が用意されているように、BunではBun.serve()が用意されています。
またBun.serve()はCloudflare Workersのようにfetchを含むオブジェクトをexport defaultとして置き換えることもできるので今回そのように書きました。

……たったこれだけソースコードを修正するだけで動くんですね。恐るべしBun。恐るべしHono。

package.json

このようにBunはDeno以上にNode.jsとの互換性を非常に重視していることがわかります。
なのでnpmで使われているpackage.jsonもNode.jsの部分をBunに書き換えるだけでパッケージのインストールやランタイムの起動が楽になります。

{
  "name": "matchbox",
  "version": "0.3.0",
  "description": "Matchbox",
  "main": "index.ts",
  "scripts": {
    "start": "bun -u $(gp url 8000) index.ts",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "repository": {
    "type": "git",
    "url": "git+https://gitlab.com/acefed/matchbox.git"
  },
  "keywords": [
    "hono"
  ],
  "author": "Acefed",
  "license": "MIT",
  "bugs": {
    "url": "https://gitlab.com/acefed/matchbox"
  },
  "homepage": "https://gitlab.com/acefed/matchbox",
  "module": "index.ts",
  "type": "module",
  "dependencies": {
    "hono": "^2.3.2"
  },
  "devDependencies": {
    "@types/node": "^18.11.7"
  }
}

このように書いたpackage.jsonを追加しておきます。

CLI

さて、BunはNode.jsやDenoに似ているものの、少し異なるコマンドが非常に多いため、CLIを使うときは注意が必要です。

$ curl https://bun.sh/install | bash
$ export BUN_INSTALL="$HOME/.bun" 
$ export PATH="$BUN_INSTALL/bin:$PATH" 

まずはBunをインストールします。
Denoと非常に似ていますね。

$ bun i --backend copyfile

bun installを行うことで、package.jsonに記載されているパッケージをインストールできます。
この際環境によってはデフォルトのhardlinkやsymlinkではパッケージを読み込みない場合があるので、その際はcopyfileを--backendオプションで指定します。

$ bun start

あとはbun run index.tsをするだけです。npmのようにpackage.jsonにstartスクリプトを用意しておけばnpm run startと書くことができ、更に短いbun startと書くこともできます。何ならbun runbunに省略できるのでbun index.tsと書いても動きます。

ここで注意したいのが、このまま動かしてもソースコード内のURLがhttp://0.0.0.0:8000/みたいな感じになり、localhostやドメインの割当ができず動作に不具合が発生する可能性があることです。

そこで--originオプションが必要になります。--origin-uに省略できます。

$ bun -u "$(gp url 8000)" index.ts

今回はGitpodで動かしたのでこのような書き方になりましたが、-u http://localhost:8080-u http://example.com:80といった感じでドメインの書き換えを行うようにしましょう。

TypeScript

Bunは拡張子が.tsでも問題なく読み込むことができますが、Denoのように型チェックなど行える機能はありません。
必要に応じてTypeScriptをインストールする必要があります。

$ bun a -d typescript
$ bun i --backend copyfile

まずはbun addを使いpackage.jsonにTypeScriptを追加します。
再度インストールすればtscコマンドが使えるようになります。

$ node_modules/.bin/tsc -v
/usr/bin/env: ‘node’: No such file or directory

いや使えるようにならんわ。
記事公開してから気づいたけどNode.jsに依存してた……。

こうして無理やり使えるようにします。

$ tar -xJf node-v18.12.0-linux-x64.tar.xz --strip-components=2 node-v18.12.0-linux-x64/bin/node
$ rm node-v18.12.0-linux-x64.tar.xz
$ ./node node_modules/.bin/tsc -v
Version 4.8.4

よかったですね。

$ ./node node_modules/.bin/tsc index.ts -t es2020 -m node16 --skipLibCheck

あとはこんな感じで書けば型チェックを行いコンパイルされたindex.jsが生成されます。

$ bun -u "$(gp url 8000)" index.js

もちろんindex.tsファイルと同様にindex.jsファイルも動かせます。

Experimental

今回紹介したBun版Matchboxはソースコード公開していませんがDiff公開してみたので多分こんな感じで順番にコマンド入力すれば動きます。

$ git clone https://gitlab.com/acefed/matchbox.git
$ cd matchbox
$ git checkout 9f9e7854
$ curl https://acefed.gitlab.io/matchbox/9f9e7854.diff | git apply
$ bash env.sh
$ curl https://bun.sh/install | bash
$ export BUN_INSTALL="$HOME/.bun"
$ export PATH="$BUN_INSTALL/bin:$PATH"
$ bun i --backend copyfile
$ bun start

実験的なやつですのでそのうち動かなくなるかもしれないです。

Outro

もうHonoとActivityPubの解説いいですよね。
何にせよBunとHonoの互換性に驚くばかりでです。
またActivityPubはWeb APIと非常に相性が良いことも分かります。

まあ現在のMatchboxにはファイルシステムやSQLiteに関する機能を実装していないので、そこら辺が絡むと一気にマイグレーションが難しくなるだろうなと予測しています。
今回は一旦筆を置き、Node.jsとDenoとBunの何が同じで何が異なるのか今後注力していきたいと思います。

Ref

https://zenn.dev/tkithrta/articles/21c681fd14228f

https://zenn.dev/yusukebe/articles/47dea431a00752

https://common-min-api.proposal.wintercg.org/

Discussion