TCP ソケット通信を行う

JS からは開けない生の TCP を tokio の TcpStream で扱う。1 往復で閉じるコマンドと、接続を State に持って受信をイベントで送る常時接続、切断の検知と区切り方を示す。

通信 対象: Tauri 2.x 更新日: 読了目安: 約10分 net-016
目次
  1. 前提条件
  2. 1. バックエンドから実装する (Rust)
  3. 1 往復だけならコマンドの中で閉じる
  4. 接続を State に持って開きっぱなしにする
  5. 区切りを決めて読む
  6. 2. フロントエンドから呼び出す (TypeScript)
  7. 動作確認
  8. よくあるエラーと対処法
  9. 注意点
  10. 関連レシピ

工作機械や PLC、社内の独自サーバー、テキストでやり取りする各種サービスなど、HTTP ではなく生の TCP で話す相手とつなぐ場面があります。Webview の JavaScript には TCP ソケットを開く API がないので、Rust 側で tokio の TcpStream を使って通信し、結果を invoke の戻り値かイベントで画面に渡します。1 回の問い合わせで終わるならコマンドの中で接続を閉じ、相手からいつ届くか分からないデータを受けるなら接続を State に持って開きっぱなしにします。UDP は UDP パケット通信を行う、待ち受ける側(サーバー)は アプリ内にローカル Web サーバーを立てる で扱います。

前提条件

プラグインと capability の権限は不要です。自作のコマンドは、アプリ用の権限定義を作っていなければそのまま呼べます。受信の通知に使う listen の権限は core:default に含まれます。Tauri の非同期ランタイムは tokio なので、ランタイムを別に起動する必要はなく、ソケットなどの機能を有効にした tokio を依存に加えるだけです。

cd src-tauri
cargo add tokio --features net,io-util,time,sync

どちらの形にするかは、やり取りの性質で決めます。

やり取り形
1 回送って 1 回応答を受ける(状態の問い合わせなど)コマンドの中で接続して閉じる
相手から任意のタイミングで届く(通知・ログ・計測値)接続を State に持ち、受信をイベントで送る
短い要求を続けて何度も送る同上(毎回つなぎ直すと遅い)

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

1 往復だけならコマンドの中で閉じる

TcpStream::connect() には時間の上限がありません。相手の電源が切れているなど応答がない場合、少なくとも Windows では約 21 秒待たされてからエラーになるので、tokio::time::timeout() で包みます。応答の読み取りも同じです。

use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
use tokio::time::timeout;

const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);

/// 接続して 1 行送り、1 行受け取って閉じる
#[tauri::command]
async fn tcp_request(addr: String, line: String) -> Result<String, String> {
    let stream = timeout(CONNECT_TIMEOUT, TcpStream::connect(&addr))
        .await
        .map_err(|_| format!("{addr} への接続がタイムアウトしました"))?
        .map_err(|e| format!("{addr} に接続できません: {e}"))?;
    let (reader, mut writer) = stream.into_split();
    writer.write_all(format!("{line}\n").as_bytes()).await.map_err(|e| e.to_string())?;

    let mut reply = String::new();
    timeout(Duration::from_secs(5), BufReader::new(reader).read_line(&mut reply))
        .await
        .map_err(|_| "応答がタイムアウトしました".to_string())?
        .map_err(|e| e.to_string())?;
    Ok(reply.trim_end().to_string()) // ここで stream の両側が破棄され、接続が閉じる
}

接続を State に持って開きっぱなしにする

into_split() で読み取り側と書き込み側に分け、書き込み側を State に、読み取り側を受信専用のタスクに渡します。タスクは tauri::async_runtime::spawn で起動し、待っている間はスレッドを占有しません。送信は .await をまたいでロックを持つので、Mutex は tokio のものにします(std との使い分けは State と Mutex でアプリの状態を管理する)。

切断は受信側で分かります。相手が正常に閉じると next_line() が None(EOF)を返し、相手のプロセスが落ちるなどしてリセットされるとエラーが返ります。困るのはケーブルが抜けた・相手の電源が落ちたときで、何も届かないまま読み取りが待ち続けます。そこで、一定時間何も届かなければ切れたとみなします。送信が成功しても相手が生きているとは限りません。相手が正常に閉じた直後でも 1 回目の書き込みは成功することがあり、失敗するのは 2 回目以降です。

use std::sync::atomic::{AtomicU64, Ordering};
use serde::Serialize;
use tauri::async_runtime::JoinHandle;
use tauri::{AppHandle, Emitter, Manager, State};
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};

/// これだけ何も届かなければ切れたとみなす(相手が定期的に何か送ってくる前提)
const IDLE_TIMEOUT: Duration = Duration::from_secs(60);

struct Conn {
    id: u64, // 接続ごとの番号。古い受信タスクが新しい接続を消さないようにする
    writer: OwnedWriteHalf,
    reader: JoinHandle<()>,
}

#[derive(Default)]
struct TcpState {
    conn: tokio::sync::Mutex<Option<Conn>>,
    next_id: AtomicU64,
}

#[derive(Clone, Serialize)]
struct Closed {
    reason: String,
}

async fn read_loop(app: AppHandle, id: u64, read_half: OwnedReadHalf) {
    let mut lines = BufReader::new(read_half).lines();
    let reason = loop {
        match timeout(IDLE_TIMEOUT, lines.next_line()).await {
            Ok(Ok(Some(line))) => {
                let _ = app.emit("tcp-line", line);
            }
            Ok(Ok(None)) => break "closed by peer".to_string(), // 相手が閉じた
            Ok(Err(e)) => break e.to_string(),                  // リセットなど
            Err(_) => break format!("{} 秒間何も届きませんでした", IDLE_TIMEOUT.as_secs()),
        }
    };
    // 自分の接続がまだ State に残っていれば片付けてから知らせる
    let state = app.state::<TcpState>();
    let mut conn = state.conn.lock().await;
    if conn.as_ref().is_some_and(|c| c.id == id) {
        *conn = None;
    }
    let _ = app.emit("tcp-closed", Closed { reason });
}

#[tauri::command]
async fn tcp_connect(app: AppHandle, state: State<'_, TcpState>, addr: String) -> Result<(), String> {
    let mut conn = state.conn.lock().await;
    if let Some(old) = conn.take() {
        old.reader.abort(); // 前の接続(再読み込み前のものなど)は閉じてから繋ぐ
    }
    let stream = timeout(CONNECT_TIMEOUT, TcpStream::connect(&addr))
        .await
        .map_err(|_| format!("{addr} への接続がタイムアウトしました"))?
        .map_err(|e| format!("{addr} に接続できません: {e}"))?;
    stream.set_nodelay(true).map_err(|e| e.to_string())?; // 短いメッセージをためずにすぐ送る
    let (read_half, writer) = stream.into_split();
    let id = state.next_id.fetch_add(1, Ordering::Relaxed);
    let reader = tauri::async_runtime::spawn(read_loop(app.clone(), id, read_half));
    *conn = Some(Conn { id, writer, reader });
    Ok(())
}

#[tauri::command]
async fn tcp_send(state: State<'_, TcpState>, line: String) -> Result<(), String> {
    let mut conn = state.conn.lock().await;
    let c = conn.as_mut().ok_or("not connected")?;
    c.writer.write_all(format!("{line}\n").as_bytes()).await.map_err(|e| e.to_string())
}

#[tauri::command]
async fn tcp_disconnect(state: State<'_, TcpState>) -> Result<(), String> {
    let taken = state.conn.lock().await.take();
    if let Some(mut c) = taken {
        c.reader.abort(); // 受信をやめる
        let _ = c.writer.shutdown().await; // 送信の終わりを伝えてから閉じる
    }
    Ok(())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .manage(TcpState::default())
        .invoke_handler(tauri::generate_handler![tcp_request, tcp_connect, tcp_send, tcp_disconnect])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

区切りを決めて読む

TCP はバイトの流れで、送った単位のまま届く保証はありません。「1 回の read() = 1 メッセージ」と考えると、2 件がくっついたり 1 件が途中で切れたりします。例では改行で区切っています。バイナリのプロトコルでは、先頭に本体の長さを付ける形がよく使われます。なお read_to_end() は相手が接続を閉じるまで戻らないので、接続を保つ通信には使えません。

use tokio::io::AsyncReadExt;

/// 先頭 4 バイト(ビッグエンディアン)の長さ + 本体、の形式で 1 件読む
async fn read_frame(reader: &mut OwnedReadHalf) -> std::io::Result<Vec<u8>> {
    let len = reader.read_u32().await? as usize;
    if len > 1024 * 1024 {
        // 壊れたデータで巨大なメモリを確保しないよう上限を設ける
        return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "frame too large"));
    }
    let mut body = vec![0u8; len];
    reader.read_exact(&mut body).await?; // 途中で切れると UnexpectedEof(これも切断)
    Ok(body)
}

Vec<u8> はそのままイベントや戻り値にでき、JS には数値の配列で届きます。

2. フロントエンドから呼び出す (TypeScript)

受信と切断の通知は、接続する前に listen で登録しておきます。後から登録すると、接続直後に届いた行を取りこぼします。ページを再読み込みしても Rust 側の接続は残りますが、tcp_connect は前の接続を閉じてから繋ぐので二重にはなりません。例では 20 秒ごとに PING を送り、相手に応答させて 60 秒の無通信判定にかからないようにしています(相手がそう応答する約束が前提です)。

import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';

type Closed = { reason: string };
let heartbeat: number | undefined;

await listen<string>('tcp-line', ({ payload }) => {
  if (payload !== 'PING') console.log('受信:', payload); // ハートビートの応答は表示しない
});
await listen<Closed>('tcp-closed', ({ payload }) => {
  window.clearInterval(heartbeat);
  console.log('切断:', payload.reason);
});

export async function connect(addr: string) {
  await invoke('tcp_connect', { addr });
  window.clearInterval(heartbeat);
  heartbeat = window.setInterval(() => {
    invoke('tcp_send', { line: 'PING' }).catch(() => {}); // 切れていれば tcp-closed が届く
  }, 20_000);
}

export const send = (line: string) => invoke('tcp_send', { line });

// 1 往復だけの問い合わせ
console.log('応答:', await invoke<string>('tcp_request', { addr: '127.0.0.1:9000', line: 'STATUS' }));

await connect('127.0.0.1:9000');
console.log('接続しました');
await send('hello');

切れたら繋ぎ直す場合は、tcp-closed を受けてから少し待って tcp_connect を呼び直します。待ち時間を 1、2、4 秒…と延ばしていく方法は WebSocket で接続してメッセージを送受信する と同じです。

動作確認

受け取ったものをそのまま返すエコーサーバーを、Node.js で別のターミナルに立てます。

node -e "require('net').createServer((s) => s.pipe(s)).listen(9000, '127.0.0.1')"

npm run tauri dev で起動して上のコードを実行すると、DevTools のコンソールに次のように出ます。

応答: STATUS
接続しました
受信: hello

続けてサーバーを Ctrl+C で止めると tcp-closed が届きます。相手が正常に閉じたときは closed by peer、プロセスごと終了したときはリセットのエラー文になり、Windows では「切断: 既存の接続はリモート ホストに強制的に切断されました。 (os error 10054)」と出ます。

よくあるエラーと対処法

  • 「対象のコンピューターによって拒否されたため、接続できませんでした。 (os error 10061)」: 相手のポートで誰も待ち受けていません(Windows の文言。Linux / macOS では Connection refused)。アドレスとポート、サーバーが起動しているかを確かめます。
  • localhost で繋ぐと毎回 2 秒ほど遅れる: localhost は IPv6 の ::1 が先に試されることがあります。サーバーが 127.0.0.1 だけで待ち受けていると、少なくとも Windows では ::1 への接続が約 2 秒後に拒否されてから IPv4 で繋がります。127.0.0.1 を直接指定します。
  • 「接続がタイムアウトしました」: 相手に届いていません。アドレスの誤り、相手の電源、途中のファイアウォールや VPN を疑います。
  • not connected: tcp_connect の前か、切断を検知して State から片付けた後に tcp_send を呼んでいます。
  • メッセージがくっつく・途中で切れる: 区切りを決めずに read() の単位で処理しています。行単位か長さ付きで読みます。

注意点

  • ファイアウォール: 接続する側では、通常 OS のファイアウォールの確認は出ません。待ち受ける側(サーバー)を作るときの注意は net-011 を参照してください。社内ネットワークでは、途中の機器で特定のポートが閉じられていることがあります。
  • 全ウィンドウに届く: emit() はすべてのウィンドウに送ります。1 つに絞るなら emit_to("main", ...) を使います(受け取り側は Rust からのイベントを受信する (listen))。
  • 暗号化されない: 生の TCP は平文です。インターネット越しなら TLS を使うか、wss:// の WebSocket を検討します。
  • アプリの終了: 終了すると受信タスクごと接続が閉じます。相手に終了を伝える必要があるプロトコルなら、終了前に tcp_disconnect を呼びます。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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