アプリ内にローカル Web サーバーを立てる

axum 0.8 と tokio でアプリ内に HTTP サーバーを立て、127.0.0.1 だけで待ち受ける。終了時の停止、ポートの選び方、WebView の fetch から呼ぶための CORS とプリフライトも示す。

通信 対象: Tauri 2.x 更新日: 読了目安: 約11分 net-011
目次
  1. 前提条件
  2. 1. バックエンドから実装する (Rust)
  3. アプリの終了時に止める
  4. 127.0.0.1 でも Host と Origin を確かめる
  5. 2. フロントエンドから呼び出す (TypeScript)
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

OAuth のリダイレクトを http://127.0.0.1:…/callback で受ける、ブラウザ拡張や CLI などの別のプログラムからアプリを操作させる、といった場面ではアプリの中に HTTP サーバーを置きます。Tauri の非同期ランタイムは tokio なので、tokio 用の axum を tauri::async_runtime::spawn でそのまま動かせます。自分の画面と Rust のやり取りは invoke で足りるので、サーバーは「アプリの外から呼ばれる」ためのものと考えます。ここでは 127.0.0.1 だけで待ち受け、終了時に止め、WebView の fetch にも CORS で答えるところまで作ります。

前提条件

axum 0.8、tokio 1(full)、Tauri 2.x で書いています。axum 0.7 とはルートの書き方が違います(「よくあるエラー」を参照)。

cd src-tauri
cargo add axum@0.8
cargo add tokio --features full

Rust で待ち受けるだけなので、プラグインも capability の権限も要りません。WebView から呼ぶときにブラウザの fetch ではなく http プラグインを使うなら、その許可に http://127.0.0.1:* を足します(書き方と使い分けは HTTP GET リクエストを送る(Rust経由))。

待ち受けるアドレスとポートは次のように決めます。

決めること値使う場面
アドレス127.0.0.1同じ PC のプログラムからだけ届く。基本はこちら
アドレス0.0.0.0LAN の他の機器からも届く。認証が必須
ポート固定(例: 43210)OAuth に登録する URL など、相手が番号を前もって知る必要があるとき
ポート0(OS が選ぶ)それ以外。番号の伝え方は 空いているポート番号を探して使う

固定するなら 49152 未満から選びます。それ以上は Windows や macOS が一時的な通信に使う範囲で、Windows では Hyper-V や WSL が起動時に予約して使えなくなることがあります。

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

use std::net::{Ipv4Addr, SocketAddr};
use std::sync::Mutex;
use std::time::Duration;

use axum::extract::{Request, State};
use axum::http::{header, HeaderValue, Method, StatusCode};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, Manager, RunEvent};
use tokio::sync::oneshot;

/// 待ち受けるポート(固定する理由がなければ 0 にして OS に選ばせる)
const PORT: u16 = 43210;

#[derive(Clone)]
struct ServerState {
    app: AppHandle,
}

#[derive(Clone, Serialize, Deserialize)]
struct Note {
    text: String,
}

/// 起動したサーバー(止めるための送信側と、サーバーのタスク)
struct LocalServer {
    port: u16,
    stop: Mutex<Option<oneshot::Sender<()>>>,
    task: Mutex<Option<tauri::async_runtime::JoinHandle<()>>>,
}

async fn health() -> &'static str {
    "ok"
}

/// 受け取ったメモを、イベントで画面へ渡す
async fn post_note(State(state): State<ServerState>, Json(note): Json<Note>) -> StatusCode {
    match state.app.emit("note-received", note) {
        Ok(()) => StatusCode::NO_CONTENT,
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
    }
}

/// WebView のオリジン(Windows / macOS・Linux / 開発中の devUrl)
fn is_app_origin(origin: &str) -> bool {
    matches!(origin, "http://tauri.localhost" | "https://tauri.localhost" | "tauri://localhost")
        || (cfg!(debug_assertions) && origin == "http://localhost:1420")
}

/// Host と Origin を確かめ、許可したオリジンにだけ CORS のヘッダーを付ける
async fn guard(req: Request, next: Next) -> Response {
    // Host が 127.0.0.1 / localhost 以外なら断る(DNS リバインディング対策)
    let host_ok = req
        .headers()
        .get(header::HOST)
        .and_then(|h| h.to_str().ok())
        .map(|h| h.rsplit_once(':').map_or(h, |(name, _)| name))
        .is_some_and(|name| name == "127.0.0.1" || name == "localhost");
    // Origin が付くのはブラウザからの要求。知らないページからなら処理しない
    let origin = req.headers().get(header::ORIGIN).cloned();
    let origin_ok = origin.as_ref().map_or(true, |o| o.to_str().is_ok_and(is_app_origin));
    if !host_ok || !origin_ok {
        return StatusCode::FORBIDDEN.into_response();
    }

    let mut res = if req.method() == Method::OPTIONS {
        StatusCode::NO_CONTENT.into_response() // プリフライトにはヘッダーだけ返す
    } else {
        next.run(req).await
    };
    if let Some(o) = origin {
        let h = res.headers_mut();
        h.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, o);
        h.insert(header::ACCESS_CONTROL_ALLOW_METHODS, HeaderValue::from_static("GET, POST"));
        h.insert(header::ACCESS_CONTROL_ALLOW_HEADERS, HeaderValue::from_static("content-type"));
        h.insert(header::VARY, HeaderValue::from_static("origin"));
    }
    res
}

fn start_server(app: &AppHandle) -> std::io::Result<LocalServer> {
    // 127.0.0.1 だけで待ち受ける(0.0.0.0 にすると LAN からも届く)
    let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, PORT));
    // 同期的に bind して、ポートが使えないことをその場で分かるようにする
    let listener = tauri::async_runtime::block_on(tokio::net::TcpListener::bind(addr))?;
    let port = listener.local_addr()?.port();

    let router = Router::new()
        .route("/health", get(health))
        .route("/notes", post(post_note))
        .layer(middleware::from_fn(guard))
        .with_state(ServerState { app: app.clone() });

    let (stop_tx, stop_rx) = oneshot::channel::<()>();
    let task = tauri::async_runtime::spawn(async move {
        let _ = axum::serve(listener, router)
            .with_graceful_shutdown(async {
                let _ = stop_rx.await;
            })
            .await;
    });
    Ok(LocalServer {
        port,
        stop: Mutex::new(Some(stop_tx)),
        task: Mutex::new(Some(task)),
    })
}

/// 新しい接続を断り、処理中の要求を最大 3 秒待つ
fn stop_server(app: &AppHandle) {
    let Some(server) = app.try_state::<LocalServer>() else {
        return; // 起動に失敗していた
    };
    let stop = server.stop.lock().unwrap().take();
    let task = server.task.lock().unwrap().take();
    if let Some(tx) = stop {
        let _ = tx.send(());
    }
    if let Some(task) = task {
        let _ = tauri::async_runtime::block_on(tokio::time::timeout(Duration::from_secs(3), task));
    }
}

/// 画面が URL を知るためのコマンド(起動に失敗していれば null)
#[tauri::command]
fn server_url(app: AppHandle) -> Option<String> {
    app.try_state::<LocalServer>()
        .map(|s| format!("http://127.0.0.1:{}", s.port))
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            match start_server(app.handle()) {
                Ok(server) => {
                    println!("listening on http://127.0.0.1:{}", server.port);
                    app.manage(server);
                }
                // サーバーが無くてもアプリは起動させ、画面で知らせる
                Err(e) => eprintln!("local server failed to start: {e}"),
            }
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![server_url])
        .build(tauri::generate_context!())
        .expect("error while building tauri application")
        .run(|app, event| {
            if let RunEvent::Exit = event {
                stop_server(app);
            }
        });
}

setup の中で block_on で bind するので、ポートが使えなければその場で分かります。失敗してもアプリは起動させ、server_url の null で画面に伝えます。

アプリの終了時に止める

プロセスが終われば OS がポートを閉じるので、止めなくても番号が使われたまま残ることはありません。止める処理が要るのは、処理中の応答(OAuth の「このタブを閉じてください」のページなど)を返し切るためです。with_graceful_shutdown は合図を受けると新しい接続を断り、処理中の要求が終わるのを待ちます。SSE や WebSocket のような終わらない接続があると待ち続けるので、timeout で 3 秒に区切っています。RunEvent::Exit は強制終了では届かず、ウィンドウを閉じても終了しない常駐アプリではサーバーも動き続けます(アプリ起動・終了時の処理を書く)。

127.0.0.1 でも Host と Origin を確かめる

127.0.0.1 は LAN から届きませんが、同じ PC の他のプログラムや、利用者がブラウザで開いている任意の Web ページからは届きます。CORS は応答を読ませない仕組みで、POST そのものはサーバーに届いて処理されます。そこで guard は、Origin が付いた要求(ブラウザからの要求)を許可したオリジン以外なら処理の前に 403 で断り、Host が 127.0.0.1 / localhost 以外の要求も断ります。<img> のような GET や curl は Origin なしで届くので、操作は GET でなく POST にし、大事な操作には起動ごとに作るトークンなどの認証も加えます。

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

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

type Note = { text: string };

// 外部のプログラムが POST /notes した内容を受け取る
await listen<Note>('note-received', (e) => console.log('受信:', e.payload.text));

// WebView の fetch からも呼べる(CORS のヘッダーは Rust の guard が付ける)
const base = await invoke<string | null>('server_url');
if (base) {
  const res = await fetch(`${base}/notes`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' }, // JSON なので先にプリフライトが送られる
    body: JSON.stringify({ text: 'WebView から' }),
  });
  console.log(res.status); // 204
} else {
  console.warn('ローカルサーバーが起動していません');
}

ページのオリジン(Windows は http://tauri.localhost、macOS・Linux は tauri://localhost、tauri dev 中は http://localhost:1420)とサーバーのオリジンは違うので、WebView の fetch は CORS の対象です。JSON の POST では本番の要求の前に OPTIONS の問い合わせ(プリフライト)が送られ、その応答に Access-Control-Allow-* が無いと本番の要求は送られません。<img> や <video> の src に URL を書くだけなら CORS は要りません。

動作確認

npm run tauri dev で起動すると、ターミナルに listening on http://127.0.0.1:43210 と出ます。別のターミナルから次を送ります(Windows の PowerShell では curl.exe と書きます)。

curl -i http://127.0.0.1:43210/health
curl -i -X POST -H "Content-Type: application/json" -d '{"text":"hello"}' http://127.0.0.1:43210/notes
curl -i -H "Origin: https://example.com" http://127.0.0.1:43210/health
curl -i -X OPTIONS -H "Origin: http://tauri.localhost" -H "Access-Control-Request-Method: POST" http://127.0.0.1:43210/notes

1 行目は 200 OK と ok、2 行目は 204 No Content で、アプリの DevTools に「受信: hello」と出ます。3 行目は 403 Forbidden です。4 行目のプリフライトには次のように返ります(date などの行は省略)。

HTTP/1.1 204 No Content
access-control-allow-origin: http://tauri.localhost
access-control-allow-methods: GET, POST
access-control-allow-headers: content-type
vary: origin

よくあるエラーと対処法

  • 「通常、各ソケット アドレスに対してプロトコル、ネットワーク アドレス、またはポートのどれか 1 つのみを使用できます。 (os error 10048)」: 日本語版 Windows で、ポートが使用中のときの文面です(macOS・Linux では「Address already in use」の趣旨)。他のアプリか、このアプリの 2 つ目の起動が使っています。e.kind() == std::io::ErrorKind::AddrInUse で判定でき、多重起動は Single Instance プラグイン で防げます。
  • 「アクセス許可で禁じられた方法でソケットにアクセスしようとしました。 (os error 10013)」: Windows が予約した範囲のポートです。netsh interface ipv4 show excludedportrange protocol=tcp で範囲を確かめ、49152 未満に変えるか 0 にします。
  • 起動直後に「Path segments must not start with :.」で始まるパニック: axum 0.7 の /:id の書き方です。0.8 では /{id}(残り全部なら /{*rest})と書きます。
  • 開発中は動くのに、ビルドすると CORS エラー: 本番のオリジン(上記)を許可していないか、useHttpsScheme で https://tauri.localhost になっています。
  • 終了に 3 秒かかる: SSE などの接続が開いたままです。timeout を短くします。

OS ごとの違いと注意点

  • Windows: 0.0.0.0 で待ち受けると初回にファイアウォールの確認画面が出ますが、127.0.0.1 だけなら通常は出ません。
  • LAN に公開する: 0.0.0.0 にすると同じネットワークの誰からも届きます。認証を付け、他の機器に見つけてもらうなら mDNS で公開 します。
  • Rust 以外で書く: Python や Node.js のサーバーは サイドカー として同梱し、番号の受け渡しは net-013 の方法で行います。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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