天然パーマです。

1KBのWebフレームワークをつくる

1KBのWebフレームワークをつくりました。 名前は「Pico」。

minifyしてビルドした模様。

コードはこれだけ。依存なし。ほんとにこれだけです。

Cloudflare WorkersとDenoで動きます。

今回はこのPicoというフレームワーク、 それに必要不可欠なURLPattern、実装について、 そして僕がつくっているもう一つのフレームワークHonoとの関係などを紹介します。

Hello World

text/plainでレスポンスを返す、“Hello World"相当のコードは4行で書けます。

import { Pico } from "@picojs/pico";

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

export default app;

Cloudflare Workersで動かしたければWranglerを使います。 開発サーバーが立ち上がってブラウザにアクセスするとHello Pico!が表示されます。

wrangler dev pico.ts

Denoはimportとエントリポイントの書き方が違いますが、同じコードがdenoコマンドで動きます。

deno run --allow-net pico.ts

Cloudflare Workersはもちろんのこと、 DenoもDeno Deployがあるので、どちらでも簡単にCDNエッジにデプロイして公開できます。

使い方

URLのパスから値を取得してJSONを返します。

app.post("/entry/:id", (c) => {
  const id = c.req.param("id");
  return c.json({
    "your id is": id,
  });
});

正規表現でマッチさせることもできます。

app.get("/post/:date(\\d+)/:title([a-z]+)", (c) => {
  const { date, title } = c.req.param();
  return c.json({ post: { date, title } });
});

クエリパラメータを取得します。

app.get("/search", (c) => {
  return c.text(`Your query is ${c.req.query("q")}`);
});

Cloudflare Workersの環境変数にアクセスできます。

app.get("/secret", (c) => {
  console.log(c.env.TOKEN);
  return c.text("Welcome!");
});

一番最後にこう書けばカスタムした「Not Found」を返却することになります。

app.all('*', () => new Response('Custom 404', { status: 404 })

応用編

Cloudflare D1を使って、タイトルと本文だけの簡単なBlogのWeb APIをつくりました。 本当に動きます。

import { Pico } from "@picojs/pico";

type Post = {
  id: number;
  title: string;
  body: string;
};

const app = new Pico();

app.get("/", async (c) => {
  const { results } = await (c.env.DB as D1Database)
    .prepare(`SELECT id,title,body FROM post;`)
    .all<Post>();
  const posts = results || [];
  return c.json({ posts: posts });
});

app.post("/post", async (c) => {
  const { title, body } = await c.req.json<Post>();
  await c.env.DB.prepare(`INSERT INTO post(title, body) VALUES(?, ?);`)
    .bind(title, body)
    .run();
  return new Response(null, {
    status: 302,
    headers: { Location: "/" },
  });
});

export default app;

これらが全て、1KBのフレームワークでできちゃいます。 すごいでしょ。

URLPatternとその背景

なぜ1KBというとても少ないコード量で実現できているのか。

それはfetchに代表されるWeb standardのAPI及び実装のおかげなのですが、 今回特に注目すべきはURLPatternというものです。

「Web standard」といっても URLPatternに関してはW3Cの標準ではなく、WinterCGが公開しているものです。

This specification was published by the Web Platform Incubator Community Group. It is not a W3C Standard nor is it on the W3C Standards Track.

そのURLPatternがCloudflare WorkersとDenoにあります。 ServiceWorkerの用途でChromeなどブラウザにも実装されています。 ちなみに、Bunでも動かしたかったのですが、URLPatternは実装されておらず、 Bunの作者に聞いたら「URLPatternは遅いし、それはWinterCGのもので、W3CのスタンダードじゃないからBunにはない」とのことです。後述するように確かに「遅いデザイン」になっていて、それは「速い」をコンセプトにしてるBunにはフィットしないでしょう。 興味深いエピソードです。

それで、URLPatternがどのようなものかと言いますと「ルーティングパターンを登録してマッチできる」というものです。Express.jsでは同じ目的でpath-to-regexpというライブラリが内部で使われているのですが、 それを参考に作られているようです。 ですので、URLPatternを使えば、ルーターがつくれるし、Webフレームワークもつくれるのです。

URLPatternの使い方

URLPatternの使い方を紹介します。

/entry/:idというルーティングパターンをつくるには以下のようにします。

const pattern = new URLPattern({ pathname: "/entry/:id" });

http://localhost/entry/123?page=2というアクセスに対し、それにマッチするかどうかを判断し、 その結果を取得するにはexecというメソッドが使えます。

const match = pattern.exec("http://localhost/entry/123?page=2");
console.log(match);

この場合はマッチするので、nullにはならず、以下のように印字されます。

{
  inputs: [ "http://localhost/entry/123?page=2" ],
  protocol: { input: "http", groups: { "0": "http" } },
  username: { input: "", groups: { "0": "" } },
  password: { input: "", groups: { "0": "" } },
  hostname: { input: "localhost", groups: { "0": "localhost" } },
  port: { input: "", groups: { "0": "" } },
  pathname: { input: "/entry/123", groups: { id: "123" } },
  search: { input: "page=2", groups: { "0": "page=2" } },
  hash: { input: "", groups: { "0": "" } }
}

pathnameに注目してください。 :idで指定した部分をキャプチャすることができています。 また、クエリパラメータはsearchに入っています。 分解されていませんが、URLSearchParamsを使えばkeyとvalueにできます。

これはひとつのパスのみを登録しましたが、 ルーターをつくるには以下のように複数のパスを配列に入れます。

const patterns: URLPattern[] = [];

patterns.push(new URLPattern({ pathname: "/" }));
patterns.push(new URLPattern({ pathname: "/about" }));
patterns.push(new URLPattern({ pathname: "/entry" }));
patterns.push(new URLPattern({ pathname: "/entry/:id" }));
patterns.push(new URLPattern({ pathname: "/entry/:id/comment" }));

そして、配列を頭からリニアに回して、受け取ったURLを渡します。 マッチしたらそのオブジェクトを返します。

const matchPattern = (url: string) => {
  for (const pattern of patterns) {
    const match = pattern.exec(url);
    if (match) return match;
  }
};

これがURLPatternを使ったルーターの実装になります。 非常に短いコードで済みます。

Picoの実装

Picoの実装を紹介します。

PicoではURLPatternオブジェクトと、 HTTPのリクエストメソッド名、マッチした時に実行するハンドラの3つをひとつのノードとして登録します。

{
  pattern: URLPattern;
  method: string;
  handler: Handler;
}

これを先程の例と同じように配列にいれて、リニアにマッチさせます。 マッチしたらハンドラを取り出します。 マッチしなかったらデフォルトの「Not Found」レスポンスを返します。 ハンドラには「拡張したRequestオブジェクト」が入った簡易なコンテキストオブジェクトを渡します。 そして、ハンドラを実行するとResponseオブジェクトが返ってくるという流れです。

Requestオブジェクトは以下のメソッドとプロパティを生やして拡張しています。

  • param()
  • query()
  • header()

このリクエストオブジェクトに加えて、コンテキストにはc.text()c.jsonという便利メソッドを生やしています。

おおよそ、これくらいのことを(minifyして)1KBのコードの中でやっています。

Honoとの互換性

僕は同じようにCloudflare WorkersとDeno、 さらにBunでも動く「Hono」というフレームワークをつくっています。 HonoとPicoには互換性があります。 import元を変えれば、Picoのアプリケーションは「ほとんど」Honoで動くのです。

import { Hono } from "hono";

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

export default app;

「ほとんど」といったのはルーターの仕様が違うからで、 正規表現のルーティングパターンは書き方を変えなくてはいけません。 また、逆にHonoのアプリケーションをPicoで動かすのは難しいです。 とはいえ、PicoのアプリケーションはHonoで動きます。 もしくは、少しだけ書き換えるだけで動きます。

これは非常に面白いです。 最初はPicoで書いていて、途中からHonoに切り替えることができます。 すごいでしょ。

“Edgy"と"General”

当然ながら、Honoの方が断然高性能です。 ルーターも3種類あってどれもURLPatternを使ったものより速く、アプリケーションによって最適なルーターを使います。 また、コンテキストオブジェクトの機能も充実していますし、ミドルウェアも使えます。

PicoはURLPatternを使ったフレームワークがなかったからつくってみました。 「つくって満足。やっぱりHonoでいいじゃん」となってしまいそうですが、 Picoには存在意義があると考えています。

当然、小さいというのはアドバンテージです。なにせ1KBです。すごい小さい。 特にCloudflare Workersでは無料プランだとJavaScriptのサイズが1MBまでという制約があるので、小さいのは嬉しいことです。

しかし、Honoもコアだけを使う分にはminifyして15KBと、絶対的に見ればそこまで大きいわけではありません。 世の中のフレームワークと比べるとだいぶ小さいです。

僕が注目している点は「Picoは『General』だ」という点です。 それに比べて「Honoは『Edgy』」です。 高速化のためのコードがだいぶ入ってますし、JSXミドルウェアやValidatorミドルウェアに代表されるように、 他のフレームワークにはない特徴的な機能も入っています。

そのため、例えばCloudflare Workersを使っことのない初心者の方に対して、最初に書くコードでは HonoよりPicoを使ったほうが「一般的には」いいかもしれない。 僕はHonoというフレームワークをこの1年間ずっとつくっていますが、 それでも他にフレームワークはいくつもあっていいと思っています。むしろそうあるべきです。 なので、初心者に対して最初からEdgyなものを紹介するより、 よりGeneralなPicoを使うように仕向けた方がその後の選択肢が広がるという意味で良いのです。 とはいえこれは「一般的には」という前提なので、 もし僕が推薦する場合は最初からHonoを薦めるでしょう。 ですので、 例えばCloudflareが公式に初心者向けにWorkersの使い方を教える際に使うルーターとしてはPicoの方がよいのではないか、 といった具合です。

Picoを使ってみて、使い心地がよければ、そのままHonoへ移行してもいいし、 他のフレームワークへ移っても良い。 Cloudflare WorkersやDenoで、Webをつくりたいという方が最初に触るフレームワークとしてPicoは最適なんじゃないかと、 妄想しています。

そういう意味でもPicoをつくった意味はすごい大きい。

まとめ

以上、Picoというフレームワークをつくって、それがURLPatternのおかげで1KBに収まっている件。 その内部の実装、そしてHonoと共存できるという考えを書きました。 まぁとはいいつつ、僕はHonoの作者で、Honoが一番いいと思っているので、 PicoもHonoもどっちも使ってください!

レポジトリは以下です。詳しい実装はこちらを参考にしてください。


この記事は「Cloudflare Advent Calendar 2022」12月5日の記事です。