チャット、共同編集、進捗の配信など、サーバーとアプリが双方向にやり取りし続ける場面では WebSocket を使います。Tauri では、Webview に組み込まれたブラウザ標準の WebSocket と、Rust 側で接続して JS から操作する websocket プラグインの 2 通りがあります。サーバーから一方向に流すだけなら SSE の方が簡単です。
前提条件
どちらを使うかは次の表で決めます。
| 比べる点 | ブラウザ標準 WebSocket | websocket プラグイン |
|---|---|---|
| 接続時のヘッダー | 付けられない | 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がブロックされます。
