メインコンテンツへスキップ
#Web制作 約9分で読めます

Astro 5.1 Server Islands でリアルタイムチャット UI を実装する【WebSocket 統合パターン】

Astro 5.1 Server Islands と WebSocket を組み合わせて、リアルタイムチャット UI を最小限の JavaScript で実装する方法を解説。SSR との使い分けと部分更新戦略を図解付きで紹介します。

Astro 5.1 Server Islands で構築するリアルタイム通信の新しいアプローチ

Astro 5.0 で導入された Server Islands は、静的サイトの一部を動的にレンダリングする革新的な機能ですが、リアルタイム通信との組み合わせはまだ十分に知られていません。

従来の SSR では、チャットやライブ通知などのリアルタイム機能を実装する際に、ページ全体を動的にレンダリングする必要がありました。これにより、静的コンテンツまで含めて毎回サーバー処理が走り、パフォーマンスが犠牲になっていました。

Astro 5.1 の Server Islands を活用すれば、チャット UI だけを動的な「島」として配置し、それ以外のコンテンツは静的に配信するハイブリッド構成が可能になります。

本記事では、Astro 5.1 Server Islands と WebSocket を統合して、リアルタイムチャット UI を実装する具体的な方法を解説します。

Server Islands と WebSocket を組み合わせるメリット

従来の SSR チャット実装の問題点

従来の Next.js や Remix などの SSR フレームワークでチャット機能を実装する場合、以下の課題がありました。

  • ページ全体が動的レンダリング対象になる: チャット部分だけでなく、ヘッダー・フッター・サイドバーなども SSR される
  • 初期レンダリングのオーバーヘッド: 静的で十分な部分まで毎回サーバー処理が走る
  • キャッシュ戦略が複雑化: 動的部分と静的部分を分離するために複雑なキャッシュ設定が必要

Server Islands によるハイブリッド構成の利点

Astro Server Islands を使うと、次のような設計が可能になります。

┌─────────────────────────────────────┐
│ 静的コンテンツ(ビルド時生成)        │
│ - ヘッダー                          │
│ - サイドバー                        │
│ - フッター                          │
│                                     │
│ ┌─────────────────────────────────┐ │
│ │ Server Island(動的レンダリング) │ │
│ │ - チャットメッセージ一覧          │ │
│ │ - WebSocket 接続                │ │
│ │ - リアルタイム更新 UI            │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────┘

主な利点:

  1. 初期表示の高速化: 静的部分は CDN から配信、動的部分のみサーバー処理
  2. JavaScript の最小化: チャット UI 部分だけに JS を限定
  3. SEO の維持: 静的コンテンツは検索エンジンに完全にインデックス可能
  4. 段階的な機能追加: 既存の静的サイトにチャット機能を追加しやすい

実装パターン: WebSocket チャットを Server Islands で構築する

プロジェクト構成

src/
├── components/
│   ├── ChatIsland.astro       # Server Island コンポーネント
│   ├── ChatMessage.astro      # メッセージ表示
│   └── ChatInput.astro        # 入力フォーム
├── pages/
│   └── chat.astro             # チャットページ(静的部分含む)
└── server/
    └── websocket.ts           # WebSocket サーバー

Server Island の定義

Astro 5.1 では、server:defer ディレクティブを使って Server Island を定義します。

---
// src/components/ChatIsland.astro
import ChatMessage from './ChatMessage.astro';
import ChatInput from './ChatInput.astro';

// サーバーサイドで最新のメッセージを取得
const messages = await fetch('http://localhost:3001/api/messages').then(r => r.json());
---

<div id="chat-island" server:defer>
  <div id="messages">
    {messages.map(msg => (
      <ChatMessage message={msg} />
    ))}
  </div>
  <ChatInput />
  <script>
    // WebSocket 接続
    const ws = new WebSocket('ws://localhost:3001');
    
    ws.onmessage = (event) => {
      const message = JSON.parse(event.data);
      const messagesDiv = document.getElementById('messages');
      
      // 新しいメッセージを DOM に追加
      const messageEl = document.createElement('div');
      messageEl.className = 'message';
      messageEl.innerHTML = `
        <strong>${message.user}</strong>: ${message.text}
        <span class="time">${new Date(message.timestamp).toLocaleTimeString()}</span>
      `;
      messagesDiv.appendChild(messageEl);
      messagesDiv.scrollTop = messagesDiv.scrollHeight;
    };
    
    // 送信処理
    document.getElementById('send-btn')?.addEventListener('click', () => {
      const input = document.getElementById('message-input') as HTMLInputElement;
      if (input.value.trim()) {
        ws.send(JSON.stringify({
          type: 'message',
          text: input.value,
          user: 'CurrentUser'
        }));
        input.value = '';
      }
    });
  </script>
</div>

<style>
  #chat-island {
    border: 1px solid #ccc;
    border-radius: 8px;
    padding: 1rem;
    max-width: 600px;
    margin: 0 auto;
  }
  
  #messages {
    height: 400px;
    overflow-y: auto;
    margin-bottom: 1rem;
    border: 1px solid #eee;
    padding: 0.5rem;
  }
  
  .message {
    margin-bottom: 0.5rem;
    padding: 0.5rem;
    background: #f5f5f5;
    border-radius: 4px;
  }
  
  .time {
    font-size: 0.75rem;
    color: #666;
    margin-left: 0.5rem;
  }
</style>

ページへの統合

---
// src/pages/chat.astro
import Layout from '../layouts/Layout.astro';
import ChatIsland from '../components/ChatIsland.astro';
---

<Layout title="リアルタイムチャット">
  <header>
    <h1>チャットルーム</h1>
    <p>Astro Server Islands + WebSocket のデモ</p>
  </header>
  
  <!-- 静的コンテンツ -->
  <aside>
    <h2>オンラインユーザー</h2>
    <ul id="users-list">
      <!-- WebSocket で更新 -->
    </ul>
  </aside>
  
  <!-- Server Island(動的コンテンツ) -->
  <main>
    <ChatIsland />
  </main>
  
  <footer>
    <p>&copy; 2026 Your Company</p>
  </footer>
</Layout>

WebSocket サーバーの実装

Astro の SSR モードでは、Node.js WebSocket サーバーを統合できます。

// src/server/websocket.ts
import { WebSocketServer } from 'ws';
import type { IncomingMessage } from 'http';
import type { Duplex } from 'stream';

const wss = new WebSocketServer({ noServer: true });

const clients = new Set<any>();
const messages: any[] = [];

wss.on('connection', (ws) => {
  clients.add(ws);
  
  // 既存メッセージを送信
  ws.send(JSON.stringify({ type: 'history', messages }));
  
  ws.on('message', (data) => {
    const message = JSON.parse(data.toString());
    
    if (message.type === 'message') {
      const newMessage = {
        ...message,
        timestamp: new Date().toISOString()
      };
      
      messages.push(newMessage);
      
      // 全クライアントにブロードキャスト
      clients.forEach(client => {
        if (client.readyState === 1) { // OPEN
          client.send(JSON.stringify(newMessage));
        }
      });
    }
  });
  
  ws.on('close', () => {
    clients.delete(ws);
  });
});

export function handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer) {
  wss.handleUpgrade(request, socket, head, (ws) => {
    wss.emit('connection', ws, request);
  });
}

Astro 設定

// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  output: 'hybrid', // Server Islands を有効化
  adapter: node({
    mode: 'standalone'
  }),
  vite: {
    server: {
      // WebSocket サーバーを統合
      configureServer(server) {
        server.httpServer?.on('upgrade', (request, socket, head) => {
          if (request.url === '/ws') {
            const { handleUpgrade } = await import('./src/server/websocket');
            handleUpgrade(request, socket, head);
          }
        });
      }
    }
  }
});

アーキテクチャ図: Server Islands + WebSocket の処理フロー

sequenceDiagram
    participant User as ユーザー
    participant CDN as CDN/静的配信
    participant Astro as Astro サーバー
    participant WS as WebSocket サーバー
    participant DB as データベース

    User->>CDN: ページリクエスト
    CDN->>User: 静的 HTML(ヘッダー/フッター)
    User->>Astro: Server Island リクエスト
    Astro->>DB: 最新メッセージ取得
    DB->>Astro: メッセージデータ
    Astro->>User: 動的 HTML(チャット UI)
    User->>WS: WebSocket 接続
    WS->>User: 接続確立
    
    User->>WS: メッセージ送信
    WS->>DB: メッセージ保存
    WS->>User: ブロードキャスト
    WS->>User: リアルタイム更新

このフローでは、初回アクセス時に静的コンテンツは CDN から高速配信され、チャット部分のみが Astro サーバーで動的生成されます。その後、WebSocket による双方向通信でリアルタイム更新が実現されます。

パフォーマンス最適化のポイント

1. Server Islands の遅延ロード戦略

Server Islands は server:defer により、メインコンテンツの後に非同期で読み込まれます。

<!-- 優先度の高いコンテンツ -->
<main>
  <h1>ページタイトル</h1>
  <p>重要な静的コンテンツ</p>
</main>

<!-- 低優先度の Server Island -->
<aside server:defer>
  <ChatIsland />
</aside>

この設定により、LCP(Largest Contentful Paint)は静的コンテンツで計測され、チャット UI の読み込み遅延がユーザー体験に影響しません。

2. WebSocket 接続の再利用

複数の Server Islands で同じ WebSocket 接続を共有する場合、グローバルな接続マネージャーを実装します。

// src/lib/websocket-client.ts
class WebSocketManager {
  private ws: WebSocket | null = null;
  private listeners: Map<string, Set<Function>> = new Map();

  connect(url: string) {
    if (this.ws?.readyState === WebSocket.OPEN) return;
    
    this.ws = new WebSocket(url);
    
    this.ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      const handlers = this.listeners.get(data.type) || new Set();
      handlers.forEach(handler => handler(data));
    };
  }

  on(eventType: string, handler: Function) {
    if (!this.listeners.has(eventType)) {
      this.listeners.set(eventType, new Set());
    }
    this.listeners.get(eventType)!.add(handler);
  }

  send(data: any) {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(data));
    }
  }
}

export const wsManager = new WebSocketManager();

3. メッセージの差分更新

全メッセージを毎回再レンダリングするのではなく、新しいメッセージだけを DOM に追加します。

// 非効率: 全メッセージを再レンダリング
messages = [...messages, newMessage];
render(messages);

// 効率的: 新しいメッセージだけ追加
const messageEl = createMessageElement(newMessage);
messagesContainer.appendChild(messageEl);

SSR との使い分け基準

要件Server Islands完全 SSR
リアルタイム性◎ WebSocket で即座に反映△ ポーリングが必要
初期表示速度◎ 静的部分は即座に表示△ サーバー処理待ち
SEO◎ 静的コンテンツは完全に index 可能◎ すべて index 可能
実装の複雑さ△ 静的/動的の分離設計が必要◎ シンプル
適したユースケースチャット、通知、ダッシュボードECサイト商品ページ、ブログ

Server Islands を選ぶべきケース:

  • ページの大部分は静的コンテンツ
  • 一部のコンポーネントだけがリアルタイム更新
  • SEO と動的機能の両立が必要

完全 SSR を選ぶべきケース:

  • ページ全体がユーザーごとに異なる内容
  • 認証が必須で、すべてのコンテンツがパーソナライズされる
  • リアルタイム性よりも実装のシンプルさを優先

実装時の注意点とトラブルシューティング

1. WebSocket 接続の切断対策

ネットワーク不安定時の自動再接続を実装します。

class ReconnectingWebSocket {
  constructor(url) {
    this.url = url;
    this.reconnectInterval = 1000;
    this.maxReconnectInterval = 30000;
    this.connect();
  }

  connect() {
    this.ws = new WebSocket(this.url);
    
    this.ws.onclose = () => {
      console.log('WebSocket 切断。再接続中...');
      setTimeout(() => {
        this.reconnectInterval = Math.min(
          this.reconnectInterval * 2,
          this.maxReconnectInterval
        );
        this.connect();
      }, this.reconnectInterval);
    };
    
    this.ws.onopen = () => {
      console.log('WebSocket 接続成功');
      this.reconnectInterval = 1000; // リセット
    };
  }
}

2. Server Islands の Hydration タイミング

Server Islands は非同期で読み込まれるため、クライアントサイドスクリプトの実行タイミングに注意が必要です。

<ChatIsland />

<script>
  // NG: Server Island の読み込み前に実行される可能性
  document.getElementById('chat-island')?.classList.add('loaded');
  
  // OK: DOMContentLoaded または MutationObserver で待機
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initChat);
  } else {
    initChat();
  }
</script>

3. メモリリークの防止

WebSocket のイベントリスナーは、コンポーネントのアンマウント時に必ず削除します。

let ws;

// ページ離脱時にクリーンアップ
window.addEventListener('beforeunload', () => {
  if (ws) {
    ws.close();
    ws = null;
  }
});

まとめ: Server Islands でリアルタイム機能を最小コストで実現

Astro 5.1 の Server Islands と WebSocket を組み合わせることで、以下を実現できます。

  • パフォーマンスと動的機能の両立: 静的コンテンツは CDN から配信、動的部分だけサーバー処理
  • JavaScript の最小化: チャット UI など必要な部分だけにスクリプトを限定
  • 段階的な機能追加: 既存の静的サイトに後からリアルタイム機能を追加可能
  • SEO の維持: 静的コンテンツは検索エンジンに完全にインデックス可能

従来の SSR では「全体を動的にするか、全体を静的にするか」の二択でしたが、Server Islands により「静的と動的を自由に組み合わせる」設計が可能になりました。

チャット機能だけでなく、ライブ通知、株価ティッカー、スポーツスコアボードなど、リアルタイム性が求められる UI コンポーネントに幅広く応用できます。

参考リンク

#Astro #Server Islands #WebSocket #リアルタイム通信 #チャットUI
シェア