今日の流れ
- 環境準備
- EventSource
- Fanout(WebSocket)
- MCP Server
ワークショップ終了時点でのゴールイメージ
- EventSource の特性を理解して特別な工夫をせずに片方向のリアルタイム通信を実現できる
- Fanout の特性を理解して WebSocket を使った双方向リアルタイム通信を行うアプリを簡単に実装できる(運用負荷の低減)
- セキュアかつ高速に動作する Fastly Compute (WebAssembly) ベースで動くリモート MCP サーバをデプロイできる
以下アカウントとコマンド類を準備
- アカウント関係
- Fastly アカウント
- 開発者アカウント(無料プラン)をまだ持っていない場合 https://fastly.com/signup より作成(クレジットカード入力不要)
- API Token
- doc 参照して Global スコープの API Token を作成しておく
- 作成した Token は
$fastly profile createまたは$export FASTLY_API_TOKEN=XXXXXXXXXXXでターミナル上で利用可能にする
- Fastly アカウント
- コマンド関係
- fastly コマンド
- Mac の場合
brew install fastly/tap/fastlyでインストール可能 - それ以外の OS は 公式ガイド 参照
- Mac の場合
- Node.js (>=16.6) と npm
which nodewhich npmして何も出てこない場合お好みの方法でインストールしておく
- git コマンド
- Pushpin
- こちらのダウンロードページからお使いの端末の OS にあわせて最新版をインストールする
- ※ windows がサポートされていないため、windows 環境の方は WSL など仮想環境のご利用をご検討ください
- こちらのダウンロードページからお使いの端末の OS にあわせて最新版をインストールする
- fastly コマンド
本節ではブラウザと 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 モードを使って同等の内容を実装する
本節では Fanout ベースで動くサンプルのリアルタイムアプリ(リーダーボード)をビルドしてローカルで動かします。
- Fanout を使ったリアルタイムアプリの実装と仕組み
- GRIP, pushpin と Fanout の関係と概要
- Fanout(pushpin/GRIP) を使った開発で得られるメリット
下記公式チュートリアルに沿って 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
以下コマンドを実行して 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 にアクセス
大雑把な処理の流れは下図で表される
もう少し詳しい処理の流れやアーキテクチャは以下の通り
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リファレンスを参照してください。
Compute Appには、「Fanoutハンドオフ」を実行せずにリクエストを処理するコードパスを含めることができます。この場合、その動作は従来のCompute Appと同じです。
- クライアントがViceroyで実行されているCompute AppにHTTPリクエストを送信します (1)
- Compute Appが標準的なHTTPレスポンスを構築します
- 標準的なバックエンドへのフェッチを含む場合があります (8)
- Compute Appがリクエスト接続を介して、標準的な短命レスポンスを送信します (1)
fastly compute publishコマンドにより production 環境へデプロイして接続実験を行う
本節では 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 を指定して接続することを確認
- Claude Code や Gemini CLI などから今回作成した MCP Server に接続してみる
fastly compute publishコマンドによりデプロイして同様に接続実験を行う