ブログに戻る

フォロー&ご登録

JavaScript Compute 向けモジュラー型 Edge Side Includes コンポーネント

大室克之

Developer Relations、Senior Software Engineer, Fastly

2022年の夏、チームメイトの Kailan は Fastly Compute 向けに Rust クレート の構築に取り組んだ際、テンプレート化のためのマークアップ言語である Edge Side Includes (ESI) のサブセットを実装し、それについてブログ記事を執筆しました。有用なライブラリを公開したという理由だけではなく、モジュール機能により Compute で プログラマブルなエッジを実現できる好例を示した点において、この記事は大きな意味を持っていました。そして Compute で JavaScript のサポートが一般公開されてから1年以上たった今、JavaScript ユーザー向けに同様のソリューションを提供したいと私たちは考えました。すでに npm で公開されている Fastly の JavaScript 向け ESI ライブラリを利用することで、パワフルな ESI 処理をアプリケーションに追加できます。

プログラマビリティとモジュール性

約10年にわたり Fastly の CDN は Edge Side Includes (ESI) をサポートしてきました。ESI は HTML ソースにある特殊なタグを解釈することで機能する、テンプレート化のためのマークアップ言語です。別のドキュメントをフェッチし、ストリームにインラインするようエッジサーバーに指示する <esi:include> タグを使用します。

index.html

<body>
<esi:include src="./header.html" />
<main>
Content
</main>
</body>

header.html

<header>Welcome to the web site</header>

出力

<body>
<header>Welcome to the web site</header>
<main>
Content
</main>
</body>

Compute がリリースされた際、「プログラマビリティ」と「モジュール性」の2点においてエッジ環境が変化しました。

Rust に対する Fastly プラットフォームのサポートが安定した後、すぐに私たちは ESI を実装する Rust のクレートを公開し、プログラマビリティを高めました。その結果、コードを使って追加のバックエンドリクエストを構築する方法やレスポンスのフラグメントを処理する方法を設定できるようになりました。バックエンドサーバーから取得していないドキュメントに ESI 処理を実行することも可能です。このようなプログラマビリティは、Fastly が提供する決まった機能セットに限られていた VCL での ESI サポートとは一線を画しています。

同時に、このアプローチはモジュール性が高く、プログラマーはこのような ESI 処理をリクエストごとに処理することも、互換性のある種類のデータを扱う別のモジュールと ESI 処理を組み合わせ、任意の順序または特定のロジック条件に基づいて適用することもできます。

次なるターゲット : JavaScript

Rust のクレートの場合と同様に、この JavaScript ライブラリもプログラマブルにしたいと私たちは考えました。Fastly の JavaScript のサポートでは一貫して Fetch API とその仲間である Streams API を積極的に受け入れてきました。Streams API の便利な機能のひとつに TransformStream インターフェイスがあり、これによりオブジェクトを介してデータがパイプ処理され、完璧な変換を ESI で実行できます。TransformStream を導入するために ESI プロセッサーを実装することで、JavaScript で書かれた Fastly Compute のアプリケーションにこれを簡単に組み込むことが可能になりした。

以下はこれを介してデータをストリームする方法を示しています。

const transformedBody = resp.body.pipeThrough(esiTransformStream);

return new Response(
  transformedBody,
  {
    status: resp.status,
    headers: resp.headers,
  },
);

「EsiTransformStream」と呼ばれる変換が直接ストリームに適用され、メモリやパフォーマンスに関する懸念を緩和できます。その結果、以下のメリットが得られます。

  • 変換を実行する前にアップストリームレスポンス全体をバッファする必要がなくなります。

  • トランスフォーマーがアップストリームレスポンスを素早く取り込み、ストリームに現れる ESI タグを処理します。トランスフォーマーが各 ESI タグの処理を終えると、結果がすぐにダウンストリームにリリースされるので、バッファを最小限に抑えることができるのです。これにより、結果がストリームされるとクライアントはすぐに最初のバイトを受信することができ、全体が処理されるまで待つ必要がなくなります。

また、これはモジュラー型の設計であるため、EsiTransformStream は数あるうちの利用可能なツールのひとつにすぎません。例えば、圧縮など他の変換をレスポンスに適用し、レスポンスを任意の数の変換ストリームを通じてパイプ処理することができます。これは完全に標準的なインターフェイスだからです。  さらに、リクエストヘッダーやパス、レスポンスのコンテンツタイプなどに基づいて特定のリクエストやレスポンスに対してのみ条件的に ESI を有効にすることも可能です。

以下は EsiTransformStream をインスタンス化する方法を示しています。

const esiTransformStream = new EsiTransformStream(req.url, req.headers, {
  fetch(input, init) {
    return fetch(input, { ...init, backend: 'origin_0' });
  }
});

コンストラクタは URL とヘッダーオブジェクトを取得し、3つ目のパラメータとして何らかのオブジェクトを追加で取得することもできます。前述のように、ESI の主な機能は結果のストリームに含める追加のテンプレートをダウンロードすることにあります。<esi:include> のタグが、テンプレートを取得する基盤となるメカニズムとして fetch を使用する場合、以下のパラメーターの目的は、このような fetch の呼び出しを設定することにあります。

  • URL は <esi:include> タグの src で相対パスを解決するのに使用されます。

  • ヘッダーはテンプレートをフェッチする追加リクエストを実行する際に使用されます。

  • 任意で追加の設定オブジェクトを使用して fetch の動作をオーバーライドしたり、フェッチしたテンプレートの処理方法やカスタムエラー処理など、別のカスタム動作を適用したりできます。

最もシンプルなケースでは、単に設定オブジェクトの fetch 値のみを使用することもできます。fetch 値が提供されない場合、グローバル fetch 関数が代わりに使用されますが、Compute では、テンプレートを含める場合 (動的バックエンド機能を使用しない限り)、使用する fetch のバックエンドを特定するために fetch 値が必要になります。上記のスニペット例では、global fetch が呼び出される前に origin_0 という名前のバックエンドが割り当てられます。

以上です!このシンプルなセットアップにより、ESI タグに対応するバックエンドとこれらのタグを処理する Compute アプリケーションを用意できます。以下は実行できる完全なサンプルです。

ESI 機能のサポート

この実装は、過去に私たちが公開したものよりも多くの ESI 機能を提供します。

エラー処理

<esi:include> タグによって参照されるファイルが、存在しないか、またはサーバーエラーによりフェッチに失敗することがあります。このような場合、onerror="continue" の属性を追加することで、エラーを無視できます。

<esi:include src="/templates/header.html" onerror="continue" />

/templates/header.html がエラーを引き起こす場合、ESI プロセッサーは何事もなかったかのようにエラーを無視し、<esi:include> タグを空の文字列に置き換えます。

また、以下のような <esi:try> ブロックを使用し、より構造化されたエラー処理を行うことも可能です。

<esi:try>
  <esi:attempt>
    Main header
    <esi:include src="/templates/header.html" />
  </esi:attempt>
  <esi:except>
    Alternative header
  </esi:except>
</esi:try>

ESI プロセッサーはまず、<esi:attempt> のコンテンツを実行します。<esi:include> タグによってエラーが生じると、ESI プロセッサーは <esi:except> のコンテンツを実行します。

成功した場合、<esi:try> ブロック全体が <esi:attempt> ブロック全体に置き換えられ、エラーが発生した場合、<esi:except> ブロック全体に置き換えられることが重要なポイントです。上記の例では、/templates/header.html によってエラーが発生すると、これにより「Main header」のテキストが出力に表示されず、「Alternative header」のテキストのみが含まれます。詳しくは、ESI 言語仕様をご覧ください。

条件を付ける

ESI では、変数にランタイムチェックを実施することによる、条件付きの実行も可能です。以下は、そのようなチェックの例です。

<esi:choose> 
    <esi:when test="$(HTTP_COOKIE{group})=='admin'"> 
        <esi:include src="/header/admin.html" /> 
    </esi:when> 
    <esi:when test="$(HTTP_COOKIE{group})=='basic'">
        <esi:include src="/header/basic.html" />
    </esi:when> 
    <esi:otherwise> 
        <esi:include src="/header/anonymous.html" /> 
    </esi:otherwise>
</esi:choose>

プロセッサーが <esi:choose> ブロックに遭遇すると、esi:when ブロックをざっと調べ、テスト属性にある式のセットを確認します。プロセッサーは、式の値が true と判断される最初の <esi:when> ブロックを実行します。どの式も値が true と判断されない場合、オプションで <esi:otherwise> ブロックが実行されます (提供されている場合)。esi:choose ブロック全体が、実行する <esi:when> または <esi:otherwise> ブロックのいずれかに完全に置き換えられます。

プロセッサーは、主にリクエスト Cookie に基づいて、限られた変数のセットを利用可能にします。上記の例では、HTTP Cookie の「group」の値が確認されます。私たちの実装は、ESI 言語仕様に基づいているので、詳細についてはこちらをご覧ください。

サポートされているタグと機能

この実装では ESI 言語仕様の以下のタグがサポートされています。

  • esi:include
  • esi:comment
  • esi:remove
  • esi:try / esi:attempt / esi:except
  • esi:choose / esi:when / esi:otherwise
  • esi:vars

同仕様でオプションとして定義されている <esi:inline> タグは、この実装には含まれません。

ESI Variables は ESI タグの属性でサポートされ、ESI Expressions は <esi:when> のテスト属性でサポートされています。また、<!--esi ...--> コメントもサポートされています。

無限の可能性をもたらすカスタム動作

機能セットだけでも十分にワクワクしますが、プログラマブルであることの大きな魅力は、さらに多くのことが可能であるという点です。テンプレートを取り入れることが ESI の主な用途ですが、ESI で実現できることは、決してそれだけではありません。以下に例を挙げます。

ドキュメントにマークアップされているタイムスタンプを、「2日前」など、相対日付として表示したいケースを考えてみましょう。これにはさまざまな方法がありますが、パフォーマンスとメモリへの影響を考慮すると、「検索/置換」をストリームで実行するのがベストです。実際、この ESI ライブラリをプログラミングするのは、これを実行するうえで望ましい選択肢と言えます。

以下のような形式で特別に作成された ESI タグを使用して、バックエンドドキュメントにタイムスタンプがエンコードされるよう定義できます。

<esi:include src="/__since/<unix-timestamp>" />

例えば以下のスニペットによって、2024年1月1日午前0時 (太平洋時間) を表示できます。

<h1>This document was written <esi:include src="/__since/1704034800" />.</h1>

次に、以下の URL パターンに対して常にシンセティックな代わりのドキュメントが提供されるように EsiTransformStream をセットアップします。

const esiTransformStream = new EsiTransformStream(req.url, req.headers, {
    fetch(input, init) {
      const url = new URL(input instanceof Request ? input.url : String(input));

      // If custom URL pattern, return synthetic Response
      const sinceMatch = /^\/__since\/(\d+)$/.exec(url.pathname);
      if (sinceMatch != null) {
        const timestampString = sinceMatch[1];
        const timestamp = parseInt(timestampString, 10);
        return new Response(
          toRelativeTimeSince(timestamp), // This function builds a relative date
          {
            headers: { 'Content-Type': 'text/plain', },
            status: 200,
          }
        );
      }

      // For all other esi:include tags, add the backend and call the global fetch
      return fetch(input, { ...init, backend: 'origin_0' });
    }
  });

プロセッサーが上記のスニペット例に遭遇すると、以下と似たような結果を出力します (何日後に実行したかによります)。

<h1>This document was written 22d ago.</h1>

バックエンドドキュメントは Fastly によってキャッシュ可能なため、後続のリクエストはキャッシュヒットのメリットを得ると同時に、この処理によって最新の相対日時を表示し続けることができます。

以下の Fiddle は、このライブサンプルです。

実際にお試しください!

@fastly/esi はすでに npm で公開されているので、あらゆる JavaScript プログラムにすぐに追加できます。Fastly Compute のプログラムでこれをエッジで実行できますが、Fetch API をサポートしている環境であれば、Compute 以外でも機能します。完全なソースコードは GitHub に公開されています。

Fastly のアカウントを作成する前でも、上記の Fiddle のいずれかをクローンしてご自身のオリジンでテストを開始できます。Fastly のグローバルネットワークでサービスを公開する準備ができたら、Compute の無料トライアルにサインアップし、npm の ESI ライブラリの使用をすぐに開始できます。

Compute を利用することでプログラマブルでモジュール性の高いエッジでニーズに合ったベストなソリューションの選択や組み合わせが可能になるほか、独自のソリューションも構築できます。Compute 向けにこのようなモジュールを提供できるのは私たちだけに限りません。どなたでもこのエコシステムに貢献し、そこから何かを得ることができるのです。Fastly のコミュニティフォーラムにぜひ参加して、皆さんの取り組みについてお聞かせください。