SvelteKit × Cloudflare WorkersでWebSocketを実装した話

WebSocketを使うと、一度接続したら切断するまで双方向にリアルタイムで通信し続けられます。SvelteKit + Cloudflare Workersでエコーサーバーを実装しました。


WebSocketとは

通常のHTTPとの違い

**HTTP(従来)**はリクエストとレスポンスの1往復で接続が終わります。最新情報を得るには定期的にリクエストを送り続ける必要があります(ポーリング)。

WebSocketは接続を維持し続け、サーバーからいつでもメッセージを送れます。

① HTTP: ブラウザ → リクエスト → サーバー → レスポンス(接続終了)

② WebSocket:
   ブラウザ → 接続要求 → サーバー
   ブラウザ ← 接続確立 ← サーバー
               ↕ 双方向通信(切断するまで続く)
   ブラウザ → メッセージ → サーバー
   ブラウザ ← メッセージ ← サーバー(サーバーから突然送れる)

ハンドシェイク

WebSocketはHTTPから始まります。最初のHTTPでのやり取りをハンドシェイクと呼びます。

① ブラウザ → サーバー
   GET /ws HTTP/1.1
   Upgrade: websocket      ← WebSocketに切り替えたい
   Connection: Upgrade
   Sec-WebSocket-Key: xxxx

② サーバー → ブラウザ
   HTTP/1.1 101 Switching Protocols  ← OK、切り替えます

③ 以降はWebSocketプロトコルで通信

Upgrade: websocket ヘッダーはブラウザが new WebSocket(url) を呼んだ瞬間に自動で設定されます。

HTTPポーリングとの比較

ポーリング(HTTP) WebSocket
仕組み 「新しいメッセージある?」を定期的に聞く サーバーからメッセージが来たら即座に受け取る
遅延 ポーリング間隔分の遅延がある ほぼゼロ
サーバー負荷 高い(無駄なリクエストが多い) 低い

向いているユースケース

ユースケース 理由
チャット リアルタイムでメッセージを受け取る
通知 サーバーから突然プッシュできる
オンラインゲーム 低遅延で双方向通信が必要
株価・為替のリアルタイム表示 常に最新データを受け取る
コラボレーションツール 複数人の操作をリアルタイム同期

実装

WebSocketPair とは

Cloudflare Workersが提供するAPIで、繋がった2つのWebSocketを同時に作ります。

const [client, server] = Object.values(new WebSocketPair());
役割
client ブラウザ側に渡すWebSocket
server Worker(サーバー)側で操作するWebSocket

hooks.server.ts でWebSocketを処理する

SvelteKitの +server.ts はHTTPリクエスト/レスポンスを前提に作られており、101 Switching Protocols のような特殊なレスポンスを返すとハングします。hooks.server.ts でSvelteKitより先に処理することで正常に動作します。

ブラウザ → リクエスト
              ↓
        【hooks.server.ts】← ここでWebSocket処理(SvelteKitより先)
              ↓
        SvelteKitのルーター(通常のページ・APIルート)
// src/hooks.server.ts
const websocket: Handle = async ({ event, resolve }) => {
  if (
    event.url.pathname === '/ws' &&
    event.request.headers.get('Upgrade') === 'websocket'
  ) {
    const [client, server] = Object.values(new WebSocketPair());

    // @ts-ignore
    server.accept();

    server.addEventListener('message', (e: MessageEvent) => {
      server.send(`Echo: ${e.data}`);
    });

    // @ts-ignore
    return new Response(null, { status: 101, webSocket: client });
  }

  return resolve(event);
};

export const handle = sequence(websocket, auth, logger);

クライアントページ

<!-- src/routes/ws/+page.svelte -->
<script lang="ts">
  let ws: WebSocket | null = null;
  let messages = $state<string[]>([]);
  let input = $state('');
  let connected = $state(false);

  function connect() {
    const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
    ws = new WebSocket(`${protocol}//${location.host}/ws`);

    ws.onopen = () => { connected = true; };
    ws.onclose = () => { connected = false; };
    ws.onmessage = (e) => {
      messages = [...messages, e.data];
    };
  }

  function send() {
    if (ws && input.trim()) {
      ws.send(input);
      messages = [...messages, `送信: ${input}`];
      input = '';
    }
  }
</script>

<h1>WebSocket デモ</h1>

{#if !connected}
  <button onclick={connect}>接続</button>
{:else}
  <p style="color: green">接続中</p>
  <input bind:value={input} placeholder="メッセージを入力" />
  <button onclick={send}>送信</button>
{/if}

<ul>
  {#each messages as msg}
    <li>{msg}</li>
  {/each}
</ul>

全体の流れ

① ブラウザが new WebSocket('ws://localhost:8787/ws') を呼ぶ
② ブラウザが Upgrade: websocket ヘッダーを自動付与してリクエスト送信
③ hooks.server.ts がヘッダーを検出してWebSocket処理に分岐
④ WebSocketPair を作成(client・server)
⑤ server.accept() で接続を受け入れ
⑥ ブラウザに client を渡して 101 レスポンスを返す
⑦ 接続確立 → ブラウザからメッセージが来るたびに message イベント発火
⑧ server.send() でエコーを返す

注意点

今回のエコーサーバーは1対1の通信のみ対応しています。複数クライアントへのブロードキャスト(チャットなど)を実現するにはCloudflare Durable Objectsが必要になります。

← トップページに戻る