天然パーマです。

Cloudflare Workersフレームワーク「Hono」の紹介

「Hono」というCloudflare Workers向けのフレームワークを作っています。

以前もYAPCの発表とZennの記事で紹介したものです。

あらためて、さかのぼってみると「Initial Commit」が去年の12月15日でそれから現在405コミット。頑張っています。これは僕個人だけのものではなく、コントビューターの方のかいもあってです。ちなみに、そういうのも考慮して、個人リポジトリでやっていましたが、ある時から「honojs」オーガナイゼーションに切り替えました。 現在のバージョンは「v1.4.5」。 APIで紆余曲折ありつつも、安定してきました。また、使ってくれる人もだんだんと増えています。 今回は「現時点での」という前置き付きで、Honoの紹介をしましょう。 ピックアップしたら40個あったので、一気に書いていきます。

1. Getting Started

Honoを使ってCloudflare Workersのアプリケーションを作ってみます。 といっても簡単です。

まずWranglerというCLIでプロジェクトの雛形を作ります。

mkdir hono-example
cd hono-example
npx wrangler init -y

Honoはnpmからインストールできます。

npm init -y
npm i hono

Wranglerが作った雛形ではsrc/index.tsがソースコードなので、それをまるごと編集しちゃいます。 これがHonoを使った最初のコードになります。

import { Hono } from "hono";
const app = new Hono();

app.get("/", (c) => c.text("Hello! Hono!"));

app.fire();

開発サーバーを立ち上げてみましょう。

npx wrangler dev

http://127.0.0.1:8787/にアクセスすると「Hello! Hono!」が見えるでしょう。

デプロイまでやっちゃいましょう。Cloudflareアカウントがあれば以下のコマンドでデプロイできます。

npx wrangler publish ./src/index.ts

インストールからデプロイまでが5ステップでできました! ほんと簡単なので、是非試してみてください!

2. Starter template

さきほどは、Wranglerとnpmコマンド使って1からプロジェクトを作りましたが、 コマンド一発でテストスクリプトも含んだプロジェクトを作れるStarter templateがあります。こちらも使ってみてください。

npx create-cloudflare my-app https://github.com/honojs/hono-minimal

ちなみに、Cloudflareの公式ドキュメントにも載せてもらってます。

3. Wrangler2

ここまで見てきた通り、開発にはWranglerの最新、「Wrangler2」を使うことをおすすめします。以前は、Miniflareとesbuildを組み合わせる方法を好んで使っていましたが、Wrangler2はその2つを内包しつつ更に便利なCLIになっています。

4. Module Workers

Cloudflare Workersには従来からのService WorkersモードとModule Workersモードがあります。 Module Workersモードでは変数やKVへの参照をローカルスコープで扱えたり、リソースへのimportが使えたりします。 HonoでModule Workersを使うためにはfire()の代わりにexport default appを使います。

const app = new Hono();
app.get("/", (c) => c.text("Hono!"));
export default app;

5. Ultrafast

Honoでは「Ultrafast」を謳っています。Honoの他にも優秀なCloudflare向けのフレームワーク・ルーターがいくつかありますが、それらに比べてだいぶ速いです。 以下は、少々複雑なルーティングのアプリケーションを使ったベンチマークスコアです。

hono - trie-router(default) x 389,510 ops/sec ±3.16% (85 runs sampled)
hono - regexp-router x 452,290 ops/sec ±2.64% (84 runs sampled)
itty-router x 206,013 ops/sec ±3.39% (90 runs sampled)
sunder x 323,131 ops/sec ±0.75% (97 runs sampled)
worktop x 191,218 ops/sec ±2.70% (91 runs sampled)
Fastest is hono - regexp-router
✨  Done in 43.56s.

ほら、速いでしょ?

6. 依存0

Honoは他のライブラリに全く依存していません。 インストールは最小限で済みます。

7. ファイルサイズ

Honoのファイルサイズは非常に小さいです。 1MB制限のあるCloudflare Workersにとって、これは嬉しいことでしょう。 ミドルウェアを使わない最小構成だと、9.5KBです。

Honoはビルトインミドルウェアが非常に豊富ですが、ミドルウェアは「使ったら初めて読み込む」という仕組みになっているので安心してください。

8. TypeScript

HonoはTypeScriptで書かれているし、TypeScriptで書くことを推奨します。 Wranglerを使えば、tsconfig.jsonを置かなくともゼロコンフィグで.tsを動かせます。 これはすごい。 Honoではパスパラメータの値がそのままリテラルタイプになったりします。

9. DX

Wranglerだとゼロコンフィグで始められ、少ないステップで開発からデプロイまでできます。 デプロイ後には、wrangler tailコマンドも使えます。 それに加えて、Honoを使えば、最小限の記述で、アプリケーションを作ることができます。 TypeScriptを活用したエディタでの補完もDX=Developer Experienceを向上させています。

10. 基本的なルーティング

さて、実装の話題です。

ルーティングは大抵のことができます。

まず、HTTPメソッドと特定のパスでのルーティング。

app.get("/", (c) => c.text("GET /"));
app.post("/", (c) => c.text("POST /"));
app.put("/", (c) => c.text("PUT /"));
app.delete("/", (c) => c.text("DELETE /"));

ワイルドカード。

app.get("/wild/*/card", (c) => {
  return c.text("GET /wild/*/card");
});

全てのHTTPメソッドを受け付けるにはallを使います。

// Any HTTP methods
app.all("/hello", (c) => c.text("Any Method /hello"));

パスパラメータ、つまり/post/123/comment/456の「123」と「456」を取りたければこう書きます。

app.get('/post/:id/comment/:comment_id', (c) => {
  const id = c.req.param('id')
  const id = c.req.param('comment_id')
  ...
})

もしくはこうやれば一気に取れる。

app.get('/posts/:id/comment/:comment_id', (c) => {
  const { id, comment_id } = c.req.param()
  ...
})

パスパラメータを正規表現で指定できます。

app.get('/post/:date{[0-9]+}/:title{[a-z]+}', (c) => {
  const { date, title } = c.req.param()
  ...
})

また、ルーティングをチェインすることなんてのもできます。

app
  .get("/endpoint", (c) => {
    return c.text("GET /endpoint");
  })
  .post((c) => {
    return c.text("POST /endpoint");
  })
  .delete((c) => {
    return c.text("DELETE /endpoint");
  });

11. ルーティング順序

ルーティングはいかようにも書けてしまいます。そこで、どれが優先されるかのルールがあります。 登録した順と、スラッシュの数(階層の深さ)によってスコアリングされて実行順が決まるのです。

app.get("/api/*", "c"); // score 1.1 <--- `/*` is special wildcard
app.get("/api/:type/:id", "d"); // score 3.2
app.get("/api/posts/:id", "e"); // score 3.3
app.get("/api/posts/123", "f"); // score 3.4
app.get("/*/*/:id", "g"); // score 3.5
app.get("/api/posts/*/comment", "h"); // score 4.6 - not match
app.get("*", "a"); // score 0.7
app.get("*", "b"); // score 0.8

この条件で、以下にアクセスします。

GET /api/posts/123

すると、マッチするのは「c, d, e, f, b, a, b」なのでそれをスコア順にソートすれば「a, b, c, d, e, f, g」になります。

このスコアリングはハンドラをミドルウェアと組み合わせて使う場合、重要になるので、必要に応じて参照してください。

12. グルーピング

大きなアプリケーションを作ったり、「v1」「v2」とバージョニングしたWeb APIを作るのに便利です。

const api = new Hono();

api.get("/", (c) => c.text("List Books")); // GET /v1
api.get("/post/:id", (c) => {
  // GET /v1/post/:id
  const id = c.req.param("id");
  return c.text("Get Book: " + id);
});
api.post("/post", (c) => c.text("Create Book")); // POST /v1/post

const app = new Hono();
app.route("/v1", api);

13. Slashの扱い

末尾にスラッシュがある場合の扱いと、ない場合の扱い方をstrictパラメータで指定できます。 デフォルトはtrue、つまり、区別しません。 falseにするとどちらも同じパスとして扱います。

const app = new Hono({ strict: false }); // Default is true
app.get("/hello", (c) => c.text("/hello or /hello/"));

14. async/await

ハンドラは「async/await」をサポートしています。 Promiseを返すfetchも使えます。

app.get("/fetch-url", async (c) => {
  const response = await fetch("https://example.com/");
  return c.text(`Status is ${response.status}`);
});

15. Response

ユーザーへのレスポンスはコアAPIのResponseオブジェクトを返せばいいのですが、HonoではContextに便利メソッドを生やしてるのでそれを使ってください。

app.get("/say", (c) => {
  return c.text("Hello!");
});

とかけば、Content-Type:text/plainが自動的に付きます。JSON、HTMLに関しても同じく適切なContent-Typeを設定して返却します。

app.get("/api", (c) => {
  return c.json({ message: "Hello!" });
});

app.get("/page", (c) => {
  return c.html("<h1>Hello! Hono!</h1>");
});

ステータスコードとヘッダを指定したければ、こう書きます。

app.post("/post", (c) => {
  return c.text("Created!", 201, {
    "X-Custom": "foo",
  });
});

また以下のようにreturnする前に指定していくこともできます。

app.get("/post", (c) => {
  // Set headers
  c.header("X-Custom", "foo");
  c.header("Content-Type", "text/plain");

  // Set HTTP status code
  c.status(201);

  // Return the response body
  return c.body("Created!");
});

16. MiddlewareとHandler

ミドルウェアとハンドラという概念があります。 ほぼ同じものなのですが、違いは以下の通りです。

  • ハンドラ - Responseオブジェクトを必ず返す。1度のディスパッチにつき1つのハンドラが呼ばれます。
  • ミドルウェア - 何も返しません。ハンドラがディスパッチする前後に呼ばれ、Requestオブジェクトの中身を見たり、Responseをいじったりします。特定のパスのみならずワイルドカードを使って、複数のパスにマッチさせることができるので、共通の処理を書きます。await next()を使って次のミドルウェアを実行していきます。

具体例を交えて解説します。 ミドルウェアはapp.useapp.getapp.postapp.put…とともに、パスを指定して登録します。 今回はビルトインミドルウェアを使っています。

// match any method, all routes
app.use("*", logger());

// specify path
app.use("/posts/*", cors());

// specify method and path
app.post("/posts/*", basicAuth(), bodyParse());

以下は、c.text()Responseを返すのでこれがハンドラになります。

app.post("/posts", (c) => c.text("Created!", 201));

全体のイメージとしてはこんな感じです。

logger() -> cors() -> basicAuth() -> bodyParse() -> *handler*

ハンドラを包むようにミドルウェアが実行されます。

17. カスタムミドルウェア

自分でミドルウェアを書けます。await next()がディスパッチのタイミングなので、その前後に処理を挟めば、x-response-timeヘッダを付加するミドルウェアはこのように書けます。

app.use("*", async (c, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  c.header("x-Response-time", `${ms}`);
});

18. ビルトインミドルウェア

Honoには予めたくさんビルトインミドルウェアが備わっています。 現在、以下があります。

これらは、例えばhono/basic-authというパスでimportできて、そのタイミングで初めて読み込まれます。 また、なるべく外部のライブラリに依存しないようできています。 ポータブルなんですね。

ではこれらの中からいくつか見ていきましょう。

19. Basic Auth

Cloudflare Workersでベーシック認証を実装するのは案外面倒ですが、これを使えば一発です。

import { Hono } from "hono";
import { basicAuth } from "hono/basic-auth";

const app = new Hono();

app.use(
  "/auth/*",
  basicAuth({
    username: "hono",
    password: "acoolproject",
  })
);

app.get("/auth/page", (c) => {
  return c.text("You are authorized");
});

app.fire();

20. Bearer Auth

API tokenなどをリクエストヘッダに込めて、それを受け付けてVerifyするような認証もミドルウェアで提供しています。

import { Hono } from "hono";
import { bearerAuth } from "hono/bearer-auth";

const app = new Hono();

const token = "honoisacool";

app.use("/auth/*", bearerAuth({ token }));

app.get("/auth/page", (c) => {
  return c.text("You are authorized");
});

app.fire();

21. JWT Auth

JWTの認証もあります。

import { Hono } from "hono";
import { jwt } from "hono/jwt";

const app = new Hono();

app.use(
  "/auth/*",
  jwt({
    secret: "it-is-very-secret",
  })
);

app.get("/auth/page", (c) => {
  return c.text("You are authorized");
});

app.fire();

22. CORS

Cloudflare WorkersをWeb APIにして外部のフロントから呼び出す、というユースケースが多くて、CORSの実装についての話題が多いですが、これもミドルウェアでやりましょう。 細かいオプション設定でもできます。

const app = new Hono();

app.use("/api/*", cors());
app.use(
  "/api2/*",
  cors({
    origin: "http://example.com",
    allowHeaders: ["X-Custom-Header", "Upgrade-Insecure-Requests"],
    allowMethods: ["POST", "GET", "OPTIONS"],
    exposeHeaders: ["Content-Length", "X-Kuma-Revision"],
    maxAge: 600,
    credentials: true,
  })
);

app.all("/api/abc", (c) => {
  return c.json({ success: true });
});
app.all("/api2/abc", (c) => {
  return c.json({ success: true });
});

23. ETag

ETagも勝手につけてくれます。

const app = new Hono();

app.use("/etag/*", etag());
app.get("/etag/abc", (c) => {
  return c.text("Hono is cool");
});

app.fire();

24. Serve Static

Cloudflare Workersにはファイルシステムという概念がなく、画像やCSS、JSなどの静的ファイルをサーブするのには「Workers Sites」という仕組みを利用しなくてはいけなく、少々面倒です。 そこで、wrangler.tomlさえ設定してしまえば、特定のディレクトリ以下に置いたファイルをそのまま配信できるミドルウェアを作りました。よく使います。

import { Hono } from "hono";
import { serveStatic } from "hono/serve-static";

const app = new Hono();

app.use("/static/*", serveStatic({ root: "./" }));
app.get("/", (c) => c.text("This is Home! You can access: /static/hello.txt"));

app.fire();

例: https://github.com/honojs/examples/tree/master/serve-static

ちなみにこれらのファイルはKVに置かれるので、1MBの制限とは別の扱いです。

25. Mustache

MustacheというHTMLを出力するためのテンプレートエンジンも使えます。 テンプレートファイルに記述するので、コードとViewを分けることができます。 ちなみに、これもKVの仕組みを使っています。

import { Hono } from "hono";
import { mustache } from "hono/mustache";

const app = new Hono();

app.use("*", mustache());

app.get("/", (c) => {
  return c.render(
    "index",
    { name: "Hono", title: "Hono mustache example" }, // Parameters
    { footer: "footer", header: "header" } // Partials
  );
});

app.fire();

Module Workersモードではこう書きます。

import { Hono } from "hono";
import { mustache } from "hono/mustache.module"; // <---

const app = new Hono();
app.use("*", mustache());
// ...

export default app;

それぞれのテンプレートファイルは以下の通りです。 Partialという仕組みを使ってヘッダとヘッダを分けています。

index.mustache:

{{> header}}
<h1>Hello! {{name}}</h1>
{{> footer}}

header.mustache:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>{{title}}</title>
  </head>
  </body>

footer.mustache:

  </body>
</html>

例: https://github.com/honojs/examples/tree/master/mustache-template

26. GraphQL

Honoを使ってGraphQLサーバーも立てられます。 実際、「Ramen API」というプロジェクトでも使っています。

graphqlライブラリを別途インストールさえすれば、GraphQLサーバーになるのです!

import { Hono } from "hono";
import { graphqlServer } from "hono/graphql-server";
import { buildSchema } from "graphql";

export const app = new Hono();

const schema = buildSchema(`
type Query {
  hello: String
}
`);

const rootValue = {
  hello: () => "Hello Hono!",
};

app.use(
  "/graphql",
  graphqlServer({
    schema,
    rootValue,
  })
);

app.fire();

27. JSX

これはまだリリースされておらず、実装段階ですが、将来組み込まれる可能性があります。 Mustacheミドルウェアのように、ちょっとしたHTMLをHonoで出力したい時にJSXのシンタックスを使えるってものです。 これはあくまでもSSRするためだけにJSXを使っているだけで、Reactでもありません。 仮想DOMは扱わず、全て文字列です。

以下は「こんな風に書けるだろう」という例です。

import { Hono } from "hono";
import { h, jsx } from "hono/jsx";

export const app = new Hono();

app.use("*", jsx());

const Layout = (props: { children?: string; link: string }) => {
  return (
    <html>
      <body>
        {props.children}
        <footer>
          <a href={props.link}>{props.link}</a>
        </footer>
      </body>
    </html>
  );
};

const Top = (props: { message: string }) => {
  return (
    <Layout link="https://github.com/honojs/hono">
      <h1>{props.message}</h1>
    </Layout>
  );
};

app.get("/", (c) => {
  const message = "Hello! Hono!";
  return c.render(<Top message={message} />);
});

export default app;

繰り返しますがこのミドルウェアは「ちょっとした」HTML向けです。 よりファットなアプリケーションではReactなりRemixを使いましょう。


ミドルウェアの解説は以上です。

28. Not Foundとエラーハンドリング

Honoではユーザー自身がNot Foundとエラーの扱いを指定できます。

app.notFound((c) => {
  return c.text("Custom 404 Message", 404);
});

エラーハンドリング用のハンドラでは、第一引数にThrowされたErrorオブジェクトが渡ってきます。

app.onError((err, c) => {
  console.error(`${err}`);
  return c.text("Custom Error Message", 500);
});

29. 2つのルーター

Honoでは内部で使うルーターが2種類あって、どちらを使うかを指定できます。 デフォルトは「TrieRouter」です。

import { RegExpRouter } from "hono/router/reg-exp-router";

const app = new Hono({ router: new RegExpRouter() });

30. TrieRouter

Honoのルーターは速いです。

TrieRouterはTrie木という構造を使っているので、登録されたパスを頭からなめる方法よりも速くなります。 また、適切な箇所でキャッシュを利用しています。

31. RegExpRouter

TriRouterより速いのがRegExpRouterです。 これは@usualomaさんが実装してくれました。 登録されたルーティングを予めひとつの大きな正規表現にして、リクエストが来たら、マッチさせるという仕組みです。 これはPerlのRouter::Boomで使われいた手法です。 ベンチマークをすると、Cloudflare Workersのみならず、他のNodeのフレームワークで使われいてるルーターでも最速レベルです。マルチマッチしない実装だと、fastifyなんかで使われいてるfind-my-wayと比べてもいい勝負、もしくは速いのですごいです。

32. テスト

Cloudflare Workersのいいところはテストが簡単に書けることです。 Miniflareの仮想環境が優秀だからでしょう。 それに加えて、jest-environment-miniflareというjestのenvironmentを使います。 設定についてはStarter templateを参照してください。

さて書き方ですが、Honoですとapp.requestを使います。

const app = new Hono();
app.get("/hello", (c) => c.text("hello!"));

というアプリがあったとして、/helloへのRequestの結果が200かどうかを試験するにはこう書けます。

test("GET /hello is ok", async () => {
  const res = await app.request("http://localhost/hello");
  expect(res.status).toBe(200);
});

簡単でしょ?

33. Bindings

Cloudflare Workersではいわゆるexport HOO=FOOで設定するような環境変数の概念がありません。 その代わり、wrangler.tomlに書いたりして登録します。 Module Workersモードの場合、その変数が環境にバインドされます。 また、以下で説明するKVやR2へのアクセスもバインドされたオブジェクトを経由します。 Honoではc.envからそれらにアクセスできます。

例えば、よくあるユースケースとして、ユーザー/パスを変数に入れて、それをBasic認証で参照させるにはこう書きます。

export interface Bindings {
  USERNAME: string;
  PASSWORD: string;
}

const api = new Hono<Bindings>();

api.post("/posts", async (c, next) => {
  const auth = basicAuth({
    username: c.env.USERNAME,
    password: c.env.PASSWORD,
  });
  await auth(c, next);
});

ジェネリクスを渡すと補完が効いて便利です。

34. KVと一緒に使う

Cloudflare Workersには「KV」という非常に素朴なkey-valueストアがあります。 それを使ってみましょう。

といっても、Service Workersモードではグローバルな名前空間を参照することになるので、特に「Honoだからこう」ということはありません。

declare let BOOKS: KVNamespace;

const book = await BOOKS.get(key);

一方、Module Workersモードでは上記したBindingsの概念を使うことになります。

export interface Bindings {
  BOOKS: KVNamespace;
}

const api = new Hono<Bindings>();

api.post("/book/:key", async (c, next) => {
  const key = c.req.param("key");
  const book = await c.env.BOOKS.get(key);
  //...
});

このように、Module Workersだとローカルスコープで参照します。

35. R2と一緒に使う

先日BetaになったR2と一緒に使ってみます。といっても、これもKVと同じでc.envにオブジェクトを生やすだけです。

interface Bindings {
  BUCKET: R2Bucket;
}

app.get("/:key", async (c) => {
  const key = c.req.param("key");
  const object = await c.env.BUCKET.get(key);
  if (!object) return c.notFound();
  data = await object.arrayBuffer();
  contentType = object.httpMetadata.contentType;

  return c.body(data, 200, {
    "Content-Type": contentType,
  });
});

R2とKVを使ったアプリを以前作ったので、そちらも参考にしてください。

36. 実用的な例

これらを踏まえて、実用的なアプリを書いていくと、以下のようなコードを拡張していくことになるでしょう。

import { Hono } from "hono";
import { cors } from "hono/cors";
import { basicAuth } from "hono/basic-auth";
import { prettyJSON } from "hono/pretty-json";
import { getPosts, getPost, createPost, Post } from "./model";

const app = new Hono();
app.get("/", (c) => c.text("Pretty Blog API"));
app.use("*", prettyJSON());
app.notFound((c) => c.json({ message: "Not Found", ok: false }, 404));

export interface Bindings {
  USERNAME: string;
  PASSWORD: string;
}

const api = new Hono<Bindings>();
api.use("/posts/*", cors());

api.get("/posts", (c) => {
  const { limit, offset } = c.req.query();
  const posts = getPosts({ limit, offset });
  return c.json({ posts });
});

api.get("/posts/:id", (c) => {
  const id = c.req.param("id");
  const post = getPost({ id });
  return c.json({ post });
});

api.post(
  "/posts",
  async (c, next) => {
    const auth = basicAuth({
      username: c.env.USERNAME,
      password: c.env.PASSWORD,
    });
    await auth(c, next);
  },
  async (c) => {
    const post = await c.req.json<Post>();
    const ok = createPost({ post });
    return c.json({ ok });
  }
);

app.route("/api", api);

export default app;

37. Fastly [email protected]でも動く

ちなみに、Cloudflare WorkersはWebスタンダードのAPIを使っており、その点ではFastly [email protected]も同じです。 なのでHonoは一部の機能(Serve StaticミドルウェアなどKVを使うもの)を除いてはFastly [email protected]でも動きます。以下が、それようのStarter templateです。

38. Deno対応??

実行環境は違えど、DenoもWeb標準のAPIを扱います。 なので、工夫をすればDenoでも動きます。 現に、Denoifyというトランスコンパラを使って吐き出したコードが動くことを確認しました!

正式に対応するか謎ですが、その可能性もありますね。 メンテナスコストがかかりそうなので、躊躇しているところです。

39. Contributor

Honoは僕だけのプロジェクトではないです。 特に@metrueさんと@usualomaさんの貢献がでかいです。@metrueさんは主にミドルウェアの実装を。 @usualomaさんはRegExpRouterを始め、パフォーマンスやリファクタリングに関わるところを実装しています。 またIssue上で議論をすることがあって、ハンドラとミドルウェアについてと、ルーティングルールについてを「濃く」やりとりしました。楽しいですね。

40. 参考プロジェクト

最後に、参考プロジェクト一覧です。 APIのデザインについてはExpressとKoaにインスパイアされている部分が大きいです。 例えばcompose.tsはKoaのコードがベースになっています。

itty-router、Sunder、Worktopは他のCloudflare Workersのルーター・フレームワークです。 itty-routerはたった35行なのがすごい。 Worktopは元Cloudflareの天才、lukeedが作っていて完成度が高いです。

TrieRouterの実装にあたってはgoblinというGoのルーター、RegExpRouterはPerlのRouter::Boomを参考にしています。

おまけ

急にTwitterでメンション飛んできて、なんだと思ったら、Wrangler2のメイン開発者@threepointoneがHonoを取り上げてくれた!Wrangler2をずっとウォッチしてて、この人すごいなーって思っていたからすごい嬉しい!

まとめ

以上、Honoについて40個のことをピックアップしてみました。 Hono、わりといい出来だし、Cloudflare Workersも楽しいので使ってみてください!