天然パーマです。

Cloudflare WorkersでちゃんとしたWebを作る

最近は Cloudflare Workers ばっかりいじってて、フレームワークまで作ってるのですが、これ、ちゃんとやればそれなりの立派な Web サイトができるので、紹介します。

できたサイト

「家系ラーメン食べたい!」というサイトを作りました。 管理者の僕が家系ラーメンを登録できて、トップでは一覧で見れて、 詳細ページに行くと写真と紹介文が見れます。

質素に見えますが、

  • コンテンツ(ラーメン屋)をどんどん追加できる。
  • プロパティを追加することも可能。
  • 画像はリサイズされる。
  • 速い。
  • OGP ちゃんと設定している。
  • favicon.icon もやってる。

と、「ちゃんと」してます。そう、ちゃんとしてます。 では、どう作っていくか。

Cloudflare Workers

Cloudflare Workers 、そのユースケースについて。 CDN のエッジで実行される、ということでスクリプトのサイズや使える API が限られています。というか node.js じゃないんす。

なので、できることが限られるという意味でも、提示されているユースケースは「ちょっとしたもの」が多いです。例として、Cloudflare が紹介している Example を列挙しましょう。

  • Return JSON
  • A/B testing with same-URL direct access
  • Respond with another site
  • Aggregate requests
  • Alter headers
  • Auth with headers
  • HTTP Basic Authentication
  • Country code redirect
  • HTTP2 server push

このように、CDN のフロントで実行される単一機能のユースケースが多いです。

一方で、この制限の中でも「それなりの」Web を作ろうということで、JSON を吐く REST API やはたま GraphQL の実装も出ています。また、最近では、React のフレームワークである「Remix」が Cloudflare Workers で動く、ということを謳っています。

まぁ Remix を使ってもいいんですが、もう少し「素朴に」「ちゃんとした」Web サイトができないかとやってみたらできました。

制限

その前に Cloudflare Workers の制限について確認します。

  • 最終的にひとつの JS にバンドルする。
  • node.js じゃない。
  • Service Worker っぽい API がベース。
  • Web Standard(URL とか)が一応使える。
  • スクリプトのサイズは合計で 1MB 以内(未満?)。
  • ファイルシステムはない。
  • その代わり KV という仕組みがある。

これ、node.js の概念のままだと、なかなか大変です。

Hono

この制限の中で、「まともに」Web を作るために作っているのが「Hono[炎]」という Web フレームワークです。

このあたりで詳しく解説しているのですが、簡単に紹介すると…

import { Hono } from 'hono'
import { logger } from 'hono/logger'

const app = new Hono()

app.use('*', logger())

app.get('/', (c) => c.text('Hello Hono!'))
app.get('/entry/:id', (c) => {
  return c.json({ 'your id': c.req.param('id') })
})

app.fire()

こんな風に書けます。便利ですね!

miniflare と Wrangler

Cloudflare Workers の開発、デプロイで欠かせないのが Wrangler という CLI です。 それに加えて、miniflare という Yet Another な環境もあります。Wrangler だけで済むのですが、miniflare を開発、Wrangler をデプロイに使ってます。

miniflare の場合、 --live-reload オプションをつけると、ライブリロードしてくれます。つまり watch だけではなく、更新ごとに勝手にブラウザを更新してくれるのです。 これすごくて、esbuild と組み合わせると

$ miniflare --live-reload --build-command='esbuild --bundle --outdir=dist ./src/index.tsx'

というワンライナーで、高速かつライブリロードに対応した開発サーバーを立ち上げることが出来ます。

開発が一段落したら、Wrangler を使ってデプロイします。

$ wrangler publish dist/index.js --name 'ohayo'

すると --name で指定した名前で https://ohayo.yusukebe.workers.dev といった URL を発行してくれて、公開されます。この開発からデプロイまでスムーズな体験はすごい。

KV

Cloudflare では KV という素朴な Key-Value ストアを利用できます。 get put delete それに加えて prefix を指定して絞り込むこともできる list があるだけです。それでもキャッシュに使えるし、なんちゃってファイルシステムに使えます。

静的ファイルのサーブ

そう、Cloudflare Workers にはファイルシステムがないのです! じゃあどうやって、静的ファイルを配信するかというと、KV を使います。 ファイルをファイルパスをキーにして KV に突っ込んで、 それに対するリクエストがあったら KV から取得して、レスポンスとして返します。

Hono では serve-static というビルトインのミドルウェアがあって、 このコードで実現できます。

import { Hono } from 'hono'
import { serveStatic } from 'hono/serve-static'

const hono = new Hono()

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

hono.fire()

ファイルシステムが使えないということは、同時にファイルシステムを扱う node.js のモジュールは全くもって使えないということですので、そういう制限も出てきます。

HTML の配信

まぁこのように大変なんで、「使える」HTML を吐くのは結構大変です。 そこで Hono では、mustache をテンプレートエンジンに使って HTML 出力ができるミドルウェアも作りました。

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 exaple' }, // Parameters
    { footer: 'footer', header: 'header' } // Partials
  )
})

app.fire()

これ、render にパラメータで渡している index.mustache とか header.mustache とか footer.mustache はファイル名なのですが、ファイルとしてじゃなくて、 KV から .mustache ファイルの中身を文字列として読み込んで Mustache に渡しています。これで「使える」HTML をわりと簡単に吐くことができます。

ReactSSR

mustache じゃ味気ないので、React を SSR しましょう。

Remix が出てきて「おお、Cloudflare Workers でも React(ベースの)SSR できるのか!」となりましたが、特に難しいことしなくとも React SSR は素朴にやれます。

といっても、 mustache の場合のように、KV にテンプレートを置くのではなく、コンパイルする際に JSX で書いたコンポーネントもろもろを .js に入れるのです。

当初、懸念していたのは、ファイルサイズですが、Hono を使って React SSR するだけではそんなに膨らみません。

ソースマップでちゃってますが、合計「42kb」です。

React SSR は ReactDOMServer.renderToString で作った HTML を Hono の c.html で返すだけです。

// renderer.tsx

const renderTemplate: Props = (props) => {
  return `<!doctype html>
  <html>
    ${props.content}
  </html>
  `
}

export const render = (component: ReactElement) => {
  const content = ReactDOMServer.renderToString(component)
  return renderTemplate({
    content: content,
  })
}
// index.tsx

app.get('/', async (c) => {
  const data = await getIes()
  const page = render(<Index data={data} />)
  return c.html(page)
})

app.get('/ie/:name', async (c) => {
  const name = c.req.param('name')
  const data = await getIeByName(name)
  // ...
  const page = render(<Page data={data} />)
  return c.html(page)
})

これで Cloudflare Workes で React SSR できちゃうんす!

microCMS

さて、KV だけでコンテンツ管理するのは辛いので、microCMS を使いましょう。 いわゆるヘッドレス CMS です。こんな感じのスキーマにしてコンテンツを入れておきます。

公式が出している SDK を使いたいところですが、 node-fetch 使っているんで、むりぽだと思うので、fetch 使って簡単に API をコールします。

export const getIes = async (): Promise<Data> => {
  const url = new URL(END_POINT)
  return await getData(url)
}

const getData = async (url: URL): Promise<Data> => {
  const request = new Request(url.toString(), {
    headers: {
      'X-MICROCMS-API-KEY': X_MICROCMS_API_KEY,
    },
  })
  const response = await fetch(request)
  json = await response.text()
  const data: Data = JSON.parse(json)
  return data
}

Cloudflare Workers ではいわゆる「環境変数」に値するものは、 Wrangler の設定ファイルである wrangler.toml に書くか、より機密性を求めるものは secret コマンドでハッシュ化して保存します。 今回は wrangler.toml に書きました。

[vars]
X_MICROCMS_API_KEY = "XXXXXXXXXXXXXXXXXX"

API のレスポンスは

export type Data = {
  contents: Content[]
  totalCount: number
  offset: number
  limit: number
}

Data の型になるので、それをそのまま React のコンポーネントに渡します。

type Props = {
  data: Data
}

const Page: FC<Props> = (props) => {
  const ie = props.data.contents[0]
  return (
    <Layout title={ie.title} image={`${ie.image.url}?w=600`}>
      <h2>{ie.title}</h2>
      <p>
        <img
          alt={ie.title}
          src={`${ie.image.url}?w=600`}
          width='600'
          height='450'
          style={{ width: '100%', height: 'auto', maxWidth: '600px' }}
        />
      </p>
      <blockquote>{ie.description}</blockquote>
    </Layout>
  )
}

React+TypeScript だと JSX 内でも補完が効いてとてもよいですね。こういうのは mustache だと無理ですよね。

さて、これでだいたい完成!十分「ちゃんと」してます。

API レスポンスのキャッシュ

とはいえ、CDN のエッジに置いてるからには速くしたい。 このままだと、microCMS のレスポンスに引っ張られてしまいます。 なので、キャッシュしましょう!

キャッシュには KV を使います。KV 大活躍ですね。 今回は URL をキーとして API レスポンスをストアします。 getData に以下の処理を追加します。

const key = KV_PREFIX + url.toString()
let json = await IEKEI.get(key)

if (!json) {
  const request = new Request(url.toString(), {
    headers: {
      'X-MICROCMS-API-KEY': X_MICROCMS_API_KEY,
    },
  })
  const response = await fetch(request)
  json = await response.text()
  await IEKEI.put(key, json)
}

これで、2 度目のアクセス以降は非常に速く返ってきます。 いい感じですね!

Webhook

さて、microCMS でのコンテンツ更新時にキャッシュを Purge しましょう。 具体的には microCMS から Webhook を飛ばして、Cloudflare Workers で受け取る。 その後、KV のキャッシュを全て消します。

「全てのキャッシュを消す」というワンタッチなメソッドは無いので、 listdelete で作ります。

export const purgeKV = async () => {
  const list = await IEKEI.list({ prefix: KV_PREFIX })
  for (const key of list.keys) {
    await IEKEI.delete(key.name)
  }
}

OGP と favicon

最後に OGP と favicon を設定して終わりです。

OGP は Helmet 使いたかったのですが、この環境でうまく動かなかったので、ベタ書きしました。

favicon は /favicon.ico にアクセスしたら静的に置いたファイルを返すとするために、serve-static でこのようにしています。

app.use('/favicon.ico', serveStatic({ root: 'public' }))

完成

これで完成です!やったことを今一度おさらいしましょう。

  • Cloudflare Workers の制限について確認
  • miniflare と Wrangler による開発、デプロイ環境
  • 静的ファイルのサーブについて
  • KV について
  • mustache テンプレートエンジンにして HTML を吐く
  • React SSR する
  • microCMS の設定
  • microCMS の API を叩いて、コンテンツ表示
  • KV を使って API のレスポンスをキャッシュ
  • Webhook を受け取ってパージ
  • OGP と favicon

これは「ちゃんとした」Web サイトです! 少なくとも僕はそう思います! Cloudflare Workers 上で「ちゃんとした」Web サイトが出来たのです!

URL

できたサイトはこちらです。 僕が消すまで残っているはずですので見てください!

こちらのコードはこちらです。

まとめ

駆け足で、主に Hono と React SSR を使って「家系ラーメン食べたい」という「ちゃんとした」Web サンプルを作った話をしてきました。 Remix 使っておけ!って感じかもしれませんが、まぁこれも悪くないです。 何よりも Hono[炎]を使いますので!

宣伝

僕がスーパーバイザー(謎)を務めるトラベルブックでもテックブログやってまーす。 一緒にやっていくエンジニアも募集してますので、ぜひ!