Skip to content

Instantly share code, notes, and snippets.

@remore
Last active September 21, 2025 05:04
Show Gist options
  • Select an option

  • Save remore/6e23a99ff06f754b0a881bc8493db0ca to your computer and use it in GitHub Desktop.

Select an option

Save remore/6e23a99ff06f754b0a881bc8493db0ca to your computer and use it in GitHub Desktop.

0. Intro

今日の流れ

  • 環境準備
  • EventSource
  • Fanout(WebSocket)
  • MCP Server

ワークショップ終了時点でのゴールイメージ

  • EventSource の特性を理解して特別な工夫をせずに片方向のリアルタイム通信を実現できる
  • Fanout の特性を理解して WebSocket を使った双方向リアルタイム通信を行うアプリを簡単に実装できる(運用負荷の低減)
  • セキュアかつ高速に動作する Fastly Compute (WebAssembly) ベースで動くリモート MCP サーバをデプロイできる

1. 環境準備

以下アカウントとコマンド類を準備

  • アカウント関係
    • Fastly アカウント
      • 開発者アカウント(無料プラン)をまだ持っていない場合 https://fastly.com/signup より作成(クレジットカード入力不要)
    • API Token
      • doc 参照して Global スコープの API Token を作成しておく
      • 作成した Token は $fastly profile create または $export FASTLY_API_TOKEN=XXXXXXXXXXX でターミナル上で利用可能にする
  • コマンド関係
    • fastly コマンド
      • Mac の場合 brew install fastly/tap/fastly でインストール可能
      • それ以外の OS は 公式ガイド 参照
    • Node.js (>=16.6) と npm
      • which node which npm して何も出てこない場合お好みの方法でインストールしておく
    • git コマンド
    • Pushpin
      • こちらのダウンロードページからお使いの端末の OS にあわせて最新版をインストールする
        • ※ windows がサポートされていないため、windows 環境の方は WSL など仮想環境のご利用をご検討ください

2. SSE(Server-Sent Events) と EventSource

本節ではブラウザと Fastly Compute だけで動くリアルタイム速報(時報)の仕組みの原型を作ります。

理解する内容

  • SSE や EventSource の実装と仕組み
  • Fastly Compute JS SDK を使った Wasm ファイルのビルド
  • Fastly Compute を使ったローカル環境でのサービス構築方法

手順

以下コマンドを実行して JS のプロジェクトを初期化

$ mkdir -p ~/tmp/serverless-workshop/event-source-prototype
$ cd ~/tmp/serverless-workshop/event-source-prototype
$ fastly compute init  # Language: の選択では [2] JavaScript を、Starter kit: の選択では [1] Default starter for JavaScript を選択
$ npm install

初期化が完了したら、以下のコードを src/index.js に保存

addEventListener("fetch", event => event.respondWith(handleRequest(event)))

const htmlText = `
<html>
<body>
Latest message:<div id="main"></div>
<script>
const evtSource = new EventSource("/stream");
evtSource.onmessage = (e) => {
  document.getElementById("main").innerHTML = e.data;
};
</script>
</body>
</html>
`;

async function handleRequest(event) {
  const req = event.request;
  const url = new URL(req.url);
  if (url.pathname === "/stream") {
    const backendResponse = await fetch("https://fastly.com");
    const filteredStream = streamFilter(backendResponse.body);
    return new Response(filteredStream, {
      headers: new Headers({ "Content-Type": "text/event-stream" }),
    });
  } else {
    return new Response(htmlText, {
      headers: new Headers({ "Content-Type": "text/html; charset=utf-8" }),
    });
  }
}

function delay(ms) {
  return new Promise(function (resolve, reject) {
    setTimeout(resolve, ms);
  });
}

const streamFilter = (inputStream) => {
  const encoder = new TextEncoder();
  const inputReader = inputStream.getReader();
  return new ReadableStream({
    async pull(controller) {
      return inputReader.read().then(async ({value: chunk, done: readerDone}) => {
        await delay(2000);
        controller.enqueue(encoder.encode("event:message\ndata:"+Date.now()+"\n\n"));
        if (readerDone) {
          //controller.close();
        }
      });
    }
  });
}

fastly compute serve コマンドで localhost でサーバを起動したら、ブラウザで http://localhost:7676/ を開いて以下の UI が表示されていることを確認。Developer Tools の Network タブの /streamを確認。

解説

  • EventSource が GET メソッドにしか対応していない
  • Server からのリアルタイム push 通知以外の用途では使えない(例:双方向コミュニケーションにはこのままでは使えない)

応用/チャレンジ課題

  • EventSource の代わりに POST でも使えるように fetch() を使って同等の内容を実装する
  • Fanout/pushpin の SSE モードを使って同等の内容を実装する

3. GRIP, pushpin と Fanout

本節では Fanout ベースで動くサンプルのリアルタイムアプリ(リーダーボード)をビルドしてローカルで動かします。

理解する内容

  • Fanout を使ったリアルタイムアプリの実装と仕組み
  • GRIP, pushpin と Fanout の関係と概要
  • Fanout(pushpin/GRIP) を使った開発で得られるメリット

手順

A) ノーマルモード - 公式チュートリアルの手順で理解する

下記公式チュートリアルに沿って SSE と WebSocket の接続まで試してみましょう。 https://www.fastly.com/documentation/guides/concepts/real-time-messaging/fanout/#run-the-service-locally

※ このチュートリアルで利用しているリポジトリの URL: https://github.com/fastly/compute-starter-kit-javascript-fanout/tree/main

B) ハードモード - 実際のデモアプリの動作を見てみる

以下コマンドを実行して Fanout のプロジェクトを初期化

$ cd ~/tmp/serverless-workshop
$ git clone https://github.com/fastly/fanout-leaderboard-demo.git
$ cd fanout-leaderboard-demo
$ vi package.json  # Node <= v22.9 を利用している場合、--env-file-if-exists オプションが使えないので --env-file に変更
$ cp .env.template .env
$ npm i
$ npm run dev

続いて、別のターミナルを開いて以下を実行

$ cd ~/tmp/serverless-workshop/fanout-leaderboard-demo/edge
$ npm i
$ npm run dev

この状態で、http://localhost:7676 にアクセス

解説

大雑把な処理の流れは下図で表される

image

もう少し詳しい処理の流れやアーキテクチャは以下の通り

image

ローカルFanoutテストアーキテクチャ

Fanoutテストを有効にして fastly compute serve を実行すると、fastly.toml で定義されたバックエンドに基づき、PushpinのインスタンスがViceroyと共に自動的に設定・起動されます。これにより、ViceroyはこのローカルのPushpinインスタンスに対して「Fanoutハンドオフ」を実行できるようになります。

  • クライアントがViceroyで実行されているCompute AppにHTTPリクエストを送信します (1)
  • Compute Appがバックエンドを指定して「Fanoutハンドオフ」を実行します (2)
    • 図にはバックエンドアプリケーションが1つしか含まれていませんが、複数サポートされています。fastly.toml にリストアップするだけで利用可能です。
  • Pushpinがリクエストをバックエンドにプロキシします (3)
  • バックエンドはチャネル名を含むGRIP命令で応答します (4)
  • Pushpinとクライアント間で、チャネルにサブスクライブされた長寿命接続が確立されます (5)
    • 視覚的に分かりやすくするため、図では長寿命接続がクライアントとPushpinの間にあるように描かれていますが、実際にはこの仮想接続もViceroyを介してトンネリングされます。

ローカルでのメッセージ発行

リアルタイムメッセージングをシミュレートするには、Pushpinがその発行エンドポイントへのPOSTリクエストを受信する必要があります。このエンドポイントはデフォルトで http://localhost:5561/publish/ で利用可能です。ローカルで実行されているバックエンドサービスから、このエンドポイントにメッセージを送信できます。

👉 なぜローカルなのか? 発行エンドポイントが http://localhost:5561/publish/ に存在するためです。もしバックエンドがリモートにある場合、このローカルURLには到達できません。

これにより、完全なリアルタイムループをローカルでテストできるようになります。

  • バックエンドがPushpinに発行メッセージを送信します (6)
  • Pushpinがそれを即座にクライアントに配信します (7)

本番環境と同じFanoutの発行フローが、ローカルで実現されます。

👉 発行メッセージのフォーマットと例については、APIリファレンスを参照してください。

Fanoutを使用しないリクエスト

Compute Appには、「Fanoutハンドオフ」を実行せずにリクエストを処理するコードパスを含めることができます。この場合、その動作は従来のCompute Appと同じです。

  • クライアントがViceroyで実行されているCompute AppにHTTPリクエストを送信します (1)
  • Compute Appが標準的なHTTPレスポンスを構築します
    • 標準的なバックエンドへのフェッチを含む場合があります (8)
  • Compute Appがリクエスト接続を介して、標準的な短命レスポンスを送信します (1)

応用/チャレンジ課題

  • fastly compute publish コマンドにより production 環境へデプロイして接続実験を行う

4. MCP Server

本節では Fastly Compute (TypeScript) ベースで動くサンプルのリモート MCP サーバをビルドしてローカルで動かします。

理解する内容

  • Fastly Compute と Hono を使った実践的な TypeScript ベースのアプリケーションの開発
  • MCP Server と Client の仕組みやデバッグ方法

手順

以下コマンドを実行して Fanout のプロジェクトを初期化

$ mkdir -p ~/tmp/serverless-workshop/mcp-client
$ cd ~/tmp/serverless-workshop/mcp-client
$ fastly compute init  # Language: の選択では [2] JavaScript を、Starter kit: の選択では [3] Default starter for TypeScript を選択
$ npm i @modelcontextprotocol/sdk zod @hono/mcp abortcontroller-polyfill

上記コマンドが実行できたら、以下のコードを src/index.js に保存

/// <reference types="@fastly/js-compute" />
import 'abortcontroller-polyfill/dist/polyfill-patch-fetch'

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

export function createMcpServer() {
  const mcpServer = new McpServer({
    name: "test-workshop-mcp-server",
    version: "0.0.1",
  });

  mcpServer.registerTool("getIPlist", {
    title: "Get list of Fastly POP IP ranges",
    description: "Get list of Fastly POP IP ranges",
    annotations: {
      readOnlyHint: true,
      openWorldHint: false,
    },
    inputSchema: {
      includeIPv6: z.boolean().describe("Return IPv6 addresses in addition to IPv4 addresses"),
    },
    outputSchema: {
      result: z.string().describe("List of Fastly POP IP ranges"),
    },
  }, async ({ includeIPv6 }) => {
    const res = await fetch("https://api.fastly.com/public-ip-list");
    const result = await res.text();
    const structuredContent = {
      result: JSON.stringify(JSON.parse(result)["addresses"] + (includeIPv6 ? JSON.parse(result)["ipv6_addresses"] : []))
    };
    return {
      content: [{
        type: "text",
        text: JSON.stringify(structuredContent),
      }],
      structuredContent,
    };
  });

  return mcpServer;
}

import { StreamableHTTPTransport } from "@hono/mcp";
import { Hono } from "hono";
import { fire } from 'hono/service-worker'

const app = new Hono();

app.get("/", (c) => {
  return c.text("Hello, MCP Server is available at /mcp");
});

app.all("/mcp", async (c) => {
  const mcpServer = createMcpServer();
  const transport = new StreamableHTTPTransport();
  await mcpServer.connect(transport);
  return transport.handleRequest(c);
});

fire(app)

tsconfig.json も以下の内容に書き換える

{
  "compilerOptions": {
    "strict": true,
    "module": "esnext",
    "target": "ES2022",
    "moduleResolution": "bundler",
    "customConditions": ["fastly"],
    "esModuleInterop": true,
    "lib": [ "ES2023" ],
    "rootDir": "src",
    "outDir": "build",
    "allowJs": false,
    "incremental": true,
    "composite": true,
    "verbatimModuleSyntax": true,
    "resolveJsonModule": true,
    "importHelpers": true,
    "sourceMap": true,
    "sourceRoot": "/",
    "inlineSources": true,
    "declarationMap": true,
    "skipLibCheck": true,
    "noEmitOnError": true,
  },
  "include": [
    "./src/**/*.js",
    "./src/**/*.ts",
  ],
  "exclude": [
    "node_modules"
  ]
}

この状態で、ターミナル上で npx @modelcontextprotocol/inspector コマンドにより MCP クライアントを起動して、Transport Type に Streamable HTTP、URL には http://localhost:7676/mcp を指定して接続することを確認

解説

image

応用/チャレンジ課題

  • Claude Code や Gemini CLI などから今回作成した MCP Server に接続してみる
  • fastly compute publish コマンドによりデプロイして同様に接続実験を行う
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment