最近は 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 フレームワークです。
loading...
このあたりで詳しく解説しているのですが、簡単に紹介すると…
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 のキャッシュを全て消します。
「全てのキャッシュを消す」というワンタッチなメソッドは無いので、
list
と delete
で作ります。
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
できたサイトはこちらです。 僕が消すまで残っているはずですので見てください!
こちらのコードはこちらです。
loading...
まとめ
駆け足で、主に Hono と React SSR を使って「家系ラーメン食べたい」という「ちゃんとした」Web サンプルを作った話をしてきました。 Remix 使っておけ!って感じかもしれませんが、まぁこれも悪くないです。 何よりも Hono[炎]を使いますので!
宣伝
僕がスーパーバイザー(謎)を務めるトラベルブックでもテックブログやってまーす。 一緒にやっていくエンジニアも募集してますので、ぜひ!