天然パーマです。

Cloudflare WorkersはSSRだけではありませーん!

Cloudflare Workersが話題になって「CDNのエッジでSSRできるのすごくない?」ってなりがちです。 たしかにものすごいのですが、Cloudflare WorkersはSSRをするためだけのものではありません。 SSGしたページに機能を追加したり、CDNのバックエンドのRequest/Responseのハンドリングに使えます。 今回はCloudflare PagesというSSGのサービスでWorkersを使えることを紹介しつつ、WorkersのSSR以外のユースケースについて考えてみます。

SSRできると嬉しい

Cloudflare Workersが話題になったのは、先日「D1」がリリースされる以前にもありました。 「RemixがCloudflare WorkersをサポートしてSSRできる!」って件もその1つです。 こちら、Zennのcatnoseさんの記事のインパクトが大きいです。

こちらの記事ではRemixをピックアップして、以下を嬉しい点として挙げています。

  1. コールドスタートを抑えるために必要なコストを削減できる
  2. スケールしてもコストが削減できる
  3. キャッシュが柔軟にやりやすい

これはほんとすごいです。 しかもこの場合、データリソースは外部のAPIから取得するとして、あとはCloudflare Workersだけで成り立ちます。 Cloudflare WorkersはSSRするためのサーバーレス・プラットフォームなのです。 KVやDOもありますし、D1が出るとなればなおさらそこで完結します。

SSGでも使える

ただし! Cloudflare WorkersはSSRするだけのものではありませーん! 「静的なページ」をあれやこれやするのに使えます。

概要を話すとSSGで生成したHTML、それに対するRequestとResponseを操作することでHTMLにちょっとした機能を追加するって感じです。

Cloudflare Pages

CloudflareにはCloudflare Pagesというサービスがあります。 HTMLをホストするのですが、JekyllやHugoという典型的なSSGアプリケーション、Next.jsやRemixといったReactフレームワークなどがPages上で動きます。 Gitリポジトリとつなげておくとpushした瞬間にリソースを取得して、Pages上でビルドしてCDNに配置をしてくれます。

ご存知の方はVercelやNetlifyを想像してもらえると合点がいきます。

かくいうこのブログもHugoで作って、Cloudflare Pagesを使っています。 Fast Buildという仕組みが入って、ビルドが非常に高速なのが気に入っています。 Vercelより速いんで、乗り換えたくらいです。

5000枚以上あるページのビルドが44s、CDNへのデプロイが25sです!

Workersが使える

最近、このCloudflare PagesにFunctionsという機能がベータで入りました。

ドキュメントにも書いてあるとおり、これはCloudflare Workersで動いており、Pagesを「フルスタックアプリケーション」にするものです。サーバーサイドで動く動的な機能をPagesに追加します。 /apiというパスを切ってSPA/CSRのためのAPIを作ることができます。 WorkersってことはKVやDOも使えます。

これWorkersとはいいつつ、ファイルベースのルーティングでかつ、記法がちょいと独特なので敬遠していましたが、先日Workersと全く同じ風に書けるのを発見したのでテンションが上がっています。

簡単です。Module Syntaxで書いたWorkersを_workers.jsという名前で置くだけです。

例えば、pages/index.htmlにヘッダを追加したい場合は以下のコードをpages/_workers.jsに置きます。

export default {
  async fetch(request, env) {
    const res = env.ASSETS.fetch(request);
    res.headers.append("x-custom", "foo");
    return res;
  },
};

今回はTypeScriptとHonoを使いたかったので、別のディレクトリにソースを置いて、esbuildで_workers.jsに書き出すというやり方でコードを書きました。最低限の構成は以下の通りです。

├── package.json
├── pages
│   ├── 404.html
│   ├── _worker.js
│   └── index.html
└── worker
    └── index.ts

package.jsonscriptsはこちら。

  "scripts": {
    "dev": "run-p dev:*",
    "dev:wrangler": "wrangler pages dev pages --live-reload",
    "dev:esbuild": "esbuild --bundle worker/index.ts --format=esm --watch --outfile=pages/_worker.js",
    "build": "esbuild --bundle worker/index.ts --format=esm --outfile=pages/_worker.js",
    "deploy": "wrangler pages publish pages"
  },

ローカルで開発するにはWranglerが使えます。pages devコマンドはまだベータクオリティで、Miniflareにバイパスしているだけですが、それで開発は十分ですし、デプロイまでできます。 pacakge.jsonをこう書いておけば、yarn devでWranglerもesbuildも走ります。さらにMiniflareには--live-reloadオプションを渡しているので、ライブリロード用のスクリプトがHTMLにインジェクトされて、更新するたびにページがリロードされます。これはDXがよい。

APIを生やす

では、具体的な実例を紹介します。 これからはCloudflare Workersのフレームワーク、Honoを使っていきます。 /apiに簡単なAPIを生やしてみましょう。

import { Hono } from "hono";

const app = new Hono();

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

app.get("*", async (c) => {
  const res = await c.env.ASSETS.fetch(c.req);
  return res;
});

export default app;

実はポイントはAPIの部分じゃなくて、c.env.ASSETS.fetch(c.req)の部分。 pages以下に置いたindex.htmlや、その他HTMLに限らないアセットファイルをフェッチして、そのままレスポンスとして返しています。 APIを作るのはCloudflare Workersでもできちゃうので、これらアセットを操作するのがPages特有です。

認証

Basic認証をかけてみましょう。

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

const app = new Hono();

const username = "foo";
const password = "bar";
app.use("/foo/*", basicAuth({ username, password }));

app.get("*", async (c) => {
  const res: Response = await c.env.ASSETS.fetch(c.req);
  return res;
});

export default app;

Honoを使っているので、/foo以下に対するBasic認証はこれだけで書けてしまいます!

以前Pagesと同じようなプラットフォームであるVercelでSSGしたサイトをホストしようとしたのですが、 Basic認証がかけられないという理由で躊躇したことがありました。 それが解決されます。

ただし、今はVercelにはベータでEdge Functionsっていう機能があって、これを使えばBasic認証もかける事ができます。

面白い話があって、この、VercelのEdge Functionsは「Cloudflare Workers」で動いているのです!!

Vercel’s Edge Functions are built on top of Cloudflare Workers. The runtime is designed for any similar provider, but we’ve chosen Cloudflare for now because they have an amazing product.

https://news.ycombinator.com/item?id=29003514

どうりで、APIが似ているわけだ。

A/Bテスト

A/Bテストなんてのもできます。

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

const ab = new Hono();
const app = new Hono();

ab.use("*", cookie());
const cookieName = "ab-test-cookie";
const currentPagePath = "/current";
const newPagePath = "/new";

ab.get("/", async (c) => {
  const url = new URL(c.req.url);
  const cookie = c.req.cookie(cookieName);

  if (cookie === "new") {
    url.pathname = url.pathname + newPagePath;
    return c.env.ASSETS.fetch(url);
  } else if (cookie === "current") {
    url.pathname = url.pathname + currentPagePath;
    return c.env.ASSETS.fetch(url);
  }

  const percentage = Math.floor(Math.random() * 100);
  let version = "current";
  if (percentage < 50) {
    url.pathname = url.pathname + newPagePath;
    version = "new";
  } else {
    url.pathname = url.pathname + currentPagePath;
  }
  const asset = await c.env.ASSETS.fetch(url);
  c.cookie(cookieName, version);
  return c.body(asset.body);
});

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

app.get("*", async (c) => {
  const res: Response = await c.env.ASSETS.fetch(c.req);
  return res;
});

export default app;

Cookieの値を見て、フェッチするページを切り替えています。

リソースヒントの追加

次。これはかなり実用的な例です。詳しく解説します。

imgタグにdata-heroという属性が書かれていたら、そのsrc属性の値を、headタグにリソースヒントとして追加します。 リソースヒントというのは以下のようなタグで、アセットを読み込む優先度を明示的に宣言します。

<link rel="preload" href="/images/important.jpg" as="image" />

LCPなど重要な画像を速く取得してほしい場合によく使うのですが、毎度headタグに追加するのが面倒なことがあります。そこでimgタグを書いてdata-heroを指定するだけで、リソースヒントされるのであれば、コーディングが楽になります。この手法はAMP Optimizerで使われています。

さて、実現するには、HTMLの中身を見なくてはいけませんが、この時に使えるのが「HTMLRewriter」というAPIです。

これは、セレクタを使ってHTMLのDOM要素を取得し加工するための、Cloudflare WorkersならではのAPIです。 例えば、古いURLから新しいURLへリライトする時に便利です。 HTMLの書き換えを正規表現で書くのも大変ですし、ありがたいです。 HTMLRewriterは今回のようなユースケースにもフィットします。

実際にできあがったコードはこちらです。

app.use("*", async (c, next) => {
  await next();
  const heroImages: string[] = [];

  let src = "";

  c.res = new HTMLRewriter()
    .on("img", {
      element(element) {
        if (element.getAttribute("data-hero") !== undefined) {
          src = element.getAttribute("src") || "";
        }
      },
    })
    .transform(c.res);

  c.res = new HTMLRewriter()
    .on("head", {
      element(element) {
        element.append(`<link rel="preload" href="${src}" as="image" />`, {
          html: true,
        });
      },
    })
    .transform(c.res);
});

app.get("*", async (c) => {
  const res = await c.env.ASSETS.fetch(c.req);
  return res;
});

export default app;

Honoのミドルウェアを作って、ResponseをHTMLRewriterで加工しています。 当然ながらSSRしたページでも同じコードで同じことができます。

そもそもCDNの前段に置くもの

というかですね、CloudflareはそもそもCDNサービスなんで、WorkersはCDNのバックエンドのResponse/Requestを操作することが第一の目的だったのかもしれません。 それがサーバーレスプラットフォームとして優秀だったので、「SSR」や「Web API」に使われていくようになったと…

真相は知りませんが、Pagesに限らず、CDNのフロントに置くのもWorkersは重宝します。 以前書いた、以下の記事が参考になるでしょう。

そういう意味では「Fastly Compute@Edge」や「AWS CloudFront Functions」「Vercel Edge Functions」なども同じように考えることができます。

Edge SEO

昨日、SEOについて調べていたら、面白い言葉を見つけまして「Edge SEO」という概念があるらしいです。

ようは、Cloudflare WorkersのようにEdgeで実行されるファンクションをSEOに役に立たせようという発想。 リソースヒントの例なんてまさにです。 他にも上記の記事では、以下のユースケースが書かれていました。

  • メタタグの追加・編集
  • セキュリティヘッダも含めたヘッダの追加、編集、削除
  • robots.txtの編集
  • カスタム404ページ、4xx&5xxエラーページのサーブ
  • リダイレクトの実装
  • HTML空白、コメントの削除
  • キャッシュバージョンを仕様したJSの動的プリレンダー
  • A/Bテスト
  • ロギング

個人的には、ホビー感覚で使っていたCloudflare Workersがビジネスマターの「SEO」に効果的である、というのは実務のプロダクトに導入しやすくなっていいです。

まとめ

以上、Cloudflare Pagesを例に挙げ、「Cloudflare WorkersはSSRだけのものじゃない」件を見てきました。 SSGしたHTMLや、CDNのバックエンドのアセットに動的な機能を追加することができます。 その結果、Basic認証やA/Bテストを叶えられるし、Edge SEOのユースケースにもフィットするわけです。 特にCloudflare Workersのシンタックスそのままで、こうした処理が書けちゃうのが強いです。

さて、Cloudflareの回し者みたいになってしまったので、ここらへんでドロン。

その他の参考文献