WebSocket で接続してメッセージを送受信する

ブラウザ標準の WebSocket と websocket プラグイン(権限 websocket:default)の使い分け、テキストとバイナリの送受信、Close とエラーでの切断検知、Ping と待ち時間を延ばす再接続を示す。

通信 対象: Tauri 2.x 更新日: 読了目安: 約11分 net-009
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. ブラウザ標準の WebSocket
  4. websocket プラグイン
  5. 切断を検知して再接続する
  6. 2. バックエンドから実装する (Rust)
  7. 動作確認
  8. よくあるエラーと対処法
  9. OS ごとの違いと注意点
  10. 関連レシピ

チャット、共同編集、進捗の配信など、サーバーとアプリが双方向にやり取りし続ける場面では WebSocket を使います。Tauri では、Webview に組み込まれたブラウザ標準の WebSocket と、Rust 側で接続して JS から操作する websocket プラグインの 2 通りがあります。サーバーから一方向に流すだけなら SSE の方が簡単です。

前提条件

どちらを使うかは次の表で決めます。

比べる点ブラウザ標準 WebSocketwebsocket プラグイン
接続時のヘッダー付けられないAuthorization なども付けられる
サーバーに届く Originアプリの Origin(開発・本番・OS で変わる)付かない(headers で指定できる)
CSP・mixed content影響を受ける受けない
証明書の検証OS の証明書ストアプラグイン内蔵のルート証明書(既定)

トークンを URL のクエリや最初のメッセージで渡せるなら標準で足ります。ヘッダー認証が必須のサーバーや、Origin を検査するサーバーにはプラグインを使います。

プラグインは npm run tauri add websocket で追加します(lib.rs への登録と capability への websocket:default の追加も行われます)。websocket:default は接続と送信の権限をまとめたもので、core:default には含まれません。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "websocket:default"
  ]
}

このプラグインの権限には接続先を絞る仕組み(スコープ)がありません。http プラグイン のように allow に URL を並べても制限されず、権限を持つウィンドウからはどの URL にも繋がります。外部のページを表示するウィンドウは windows に入れないでください。

1. フロントエンドから実装する (TypeScript)

ブラウザ標準の WebSocket

// プラグインも権限も不要。ヘッダーは付けられない
export function openStandard(url: string) {
  const ws = new WebSocket(url);
  ws.binaryType = 'arraybuffer'; // バイナリを Blob ではなく ArrayBuffer で受ける

  ws.addEventListener('open', () => {
    ws.send(JSON.stringify({ type: 'chat', text: 'hello' }));
    ws.send(new Uint8Array([1, 2, 3])); // 標準 API はそのままバイナリを送れる
  });
  ws.addEventListener('message', (e: MessageEvent<string | ArrayBuffer>) => {
    if (typeof e.data === 'string') console.log('text:', e.data);
    else console.log('binary:', new Uint8Array(e.data));
  });
  ws.addEventListener('close', (e: CloseEvent) => {
    // 1000 = 正常終了、1006 = Close を交わさずに切れた
    console.log('closed:', e.code, e.reason, e.wasClean);
  });
  return ws;
}

サーバーが接続を拒否した理由(401 など)は JS からは見えず、close の code が 1006 になるだけです。

websocket プラグイン

connect() は接続が確立してから解決する Promise を返し、拒否されると例外になります。受信は addListener() で登録し、戻り値の関数で解除します。

import TauriWebSocket, { type Message } from '@tauri-apps/plugin-websocket';

type Chat = { type: 'chat'; text: string };

export async function openWithPlugin(url: string, token: string) {
  const ws = await TauriWebSocket.connect(url, {
    headers: { Authorization: `Bearer ${token}` }, // 例: 拒否されると "HTTP error: 401 Unauthorized"
  });

  const removeListener = ws.addListener((msg: Message | string) => {
    if (typeof msg === 'string') {
      console.warn('通信エラーで切断:', msg); // エラーは文字列で届く
      return;
    }
    switch (msg.type) {
      case 'Text':
        try {
          console.log('chat:', (JSON.parse(msg.data) as Chat).text);
        } catch {
          console.log('text:', msg.data); // JSON ではないテキスト
        }
        break;
      case 'Binary':
        console.log('binary:', new Uint8Array(msg.data)); // number[] で届く
        break;
      case 'Close':
        console.log('closed:', msg.data?.code, msg.data?.reason);
        break;
      // Ping / Pong も届くが、ここでは使わない
    }
  });

  const chat: Chat = { type: 'chat', text: 'hello' };
  await ws.send(JSON.stringify(chat));                  // テキスト
  await ws.send(Array.from(new Uint8Array([1, 2, 3]))); // バイナリは number[] にして送る

  return async () => {
    removeListener();
    await ws.disconnect(); // Close(コード 1000)を送る
  };
}

受信した値は type で見分けます。Ping / Pong / Close も届き、通信エラーで切れたときはエラーの文字列が届くので、引数を Message | string として分岐します。バイナリは number[] でやり取りし、Uint8Array は Array.from() で変換してから送ります。

切断を検知して再接続する

切断は、相手からの Close、エラーの文字列、「何も届かなくなる」の 3 通りで現れます。3 つ目はスリープ復帰や Wi-Fi の切り替えで起きやすく、どちらの方式でも何の通知もなく止まることがあります。そこで定期的に Ping を送り、Pong を含めて何も届かない状態が続いたら切れたとみなします(Ping に Pong を返すのは WebSocket の仕様で決まった動作です)。再接続の待ち時間は 1、2、4 秒…と最大 30 秒まで延ばし、ばらつきを加えて、サーバーの復旧直後に全員が同時に繋ぎに来ないようにします。

import TauriWebSocket, { type Message } from '@tauri-apps/plugin-websocket';

type Status = 'open' | 'reconnecting' | 'closed';

export class ReconnectingSocket {
  private ws: TauriWebSocket | null = null;
  private generation = 0; // 接続ごとの番号。古い接続からの通知を無視するのに使う
  private retry = 0;
  private stopped = true;
  private lastSeen = 0;
  private heartbeat: number | undefined;
  private timer: number | undefined;

  constructor(
    private readonly url: string,
    private readonly onText: (text: string) => void,
    private readonly onStatus: (status: Status) => void = () => {},
  ) {}

  start() {
    this.stopped = false;
    void this.open();
  }

  stop() {
    this.stopped = true;
    this.drop(this.generation);
  }

  // 待ち時間を飛ばしてすぐ繋ぎ直す(online イベントなどから呼ぶ)
  reconnectNow() {
    if (this.stopped || this.ws) return;
    this.retry = 0;
    window.clearTimeout(this.timer);
    void this.open();
  }

  async send(text: string) {
    if (!this.ws) throw new Error('not connected');
    await this.ws.send(text);
  }

  private async open() {
    if (this.stopped) return;
    const gen = ++this.generation;
    try {
      const ws = await TauriWebSocket.connect(this.url);
      if (gen !== this.generation) {
        void ws.disconnect().catch(() => {}); // 待っている間に stop された
        return;
      }
      this.ws = ws;
      this.retry = 0;
      this.lastSeen = Date.now();
      ws.addListener((msg: Message | string) => this.onMessage(gen, msg));
      this.heartbeat = window.setInterval(() => this.checkAlive(gen), 20_000);
      this.onStatus('open');
    } catch (e) {
      console.warn('接続に失敗:', e);
      this.drop(gen);
    }
  }

  private onMessage(gen: number, msg: Message | string) {
    if (gen !== this.generation) return;
    this.lastSeen = Date.now(); // Pong を含め、何か届けば生きている
    if (typeof msg === 'string' || msg.type === 'Close') this.drop(gen);
    else if (msg.type === 'Text') this.onText(msg.data);
  }

  // 20 秒ごとに Ping を送り、45 秒何も届かなければ切れたとみなす
  private checkAlive(gen: number) {
    if (Date.now() - this.lastSeen > 45_000) return this.drop(gen);
    this.ws?.send({ type: 'Ping', data: [] }).catch(() => this.drop(gen));
  }

  // 今の接続を捨て、stop されていなければ再接続を予約する
  private drop(gen: number) {
    if (gen !== this.generation) return; // 同じ接続で 2 回呼ばれても 1 回だけ処理する
    this.generation++;
    window.clearInterval(this.heartbeat);
    window.clearTimeout(this.timer);
    this.ws?.disconnect().catch(() => {}); // すでに切れていれば失敗するので無視する
    this.ws = null;
    if (this.stopped) return this.onStatus('closed');
    this.onStatus('reconnecting');
    // 1, 2, 4 … 最大 30 秒。0.5〜1 倍のばらつきを加える
    const delay = Math.min(30_000, 1000 * 2 ** this.retry++) * (0.5 + Math.random() / 2);
    this.timer = window.setTimeout(() => void this.open(), delay);
  }
}
// (続き) 使い方
const socket = new ReconnectingSocket(
  'wss://echo.websocket.org',
  (text) => console.log('受信:', text),
  (status) => console.log(`[ws] ${status}`),
);
socket.start();
window.addEventListener('online', () => socket.reconnectNow()); // 回線が戻ったらすぐ繋ぎ直す
window.addEventListener('beforeunload', () => socket.stop()); // 再読み込みで接続を残さない
document.querySelector('button')?.addEventListener('click', () => {
  void socket.send('hello').catch(console.error);
});

回線が戻ったかどうかの判定は インターネット接続状態を監視する で扱っています。標準の WebSocket でも close イベントで同じように再接続を予約します。ただし Ping を送る手段がないので、アプリで決めた「ping」メッセージをサーバーと送り合います。

2. バックエンドから実装する (Rust)

Rust 側はプラグインの登録だけで、tauri add を使えば自動で入ります。

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_websocket::init())
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

接続は Rust 側で保持されるので、ページを再読み込みしても閉じません。開発中の全体リロードのたびに古い接続が残り、コンソールには「[TAURI] Couldn't find callback id」で始まる警告が出ます。上の例のように beforeunload で切断します。

社内の認証局の証明書を使うサーバーや、通信を検査するプロキシがある環境では、プラグインだけ証明書のエラーになることがあります。既定で内蔵のルート証明書を使うためで、OS の証明書ストアを使う機能に切り替えます。

[dependencies]
tauri-plugin-websocket = { version = "2", default-features = false, features = ["rustls-tls-native-roots"] }

動作確認

npm run tauri dev で起動すると、上のコードが wss://echo.websocket.org(送ったものを返す公開サーバー)に繋ぎ、ボタンを押すたびに同じ文字列が返ってきます。接続直後にこのサーバーの挨拶(Request served by …)が表示されることもあります。

[ws] open
受信: hello

Wi-Fi を切ると 1 分ほどで [ws] reconnecting が出て再接続を繰り返し、戻すと online イベントですぐに [ws] open に戻ります。

よくあるエラーと対処法

  • 「websocket.connect not allowed. Permissions associated with this command: websocket:allow-connect, websocket:default」: 権限の追加漏れです。リリースビルドでは「Command plugin:websocket|connect not allowed by ACL」だけになります。
  • 「plugin websocket not found」: lib.rs に .plugin(tauri_plugin_websocket::init()) がありません。
  • connect() が「HTTP error: 401 Unauthorized」などで失敗する: サーバーが接続を拒否しています。トークンや headers を見直します。http:// の URL を渡したときは「URL error: URL scheme not supported」になります。
  • send() が「connection not found for the given id: …」で失敗する: Close を受けて接続が片付いた後です。再接続してから送ります。
  • 開発では繋がるのに本番だけ標準の WebSocket が拒否される: サーバーが Origin を検査しています。下の本番用の Origin を許可するか、プラグインで接続します。

OS ごとの違いと注意点

  • Origin: 標準の WebSocket が送る Origin は、開発中は devUrl(Vite のテンプレートなら http://localhost:1420)、本番は Windows と Android で http://tauri.localhost、macOS と Linux で tauri://localhost です。
  • Windows / Android の useHttpsScheme: ウィンドウ設定で true にすると Origin が https://tauri.localhost になり、標準の WebSocket では暗号化なしの ws:// のサーバーに繋げなくなります(mixed content)。プラグインは影響を受けません。
  • CSP: app.security.csp を設定しているなら、connect-src に接続先を足さないと標準の WebSocket がブロックされます。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する