tauri2-guide/tauri2-docs/ws_server_client_inject_guide.md
Z User 5e7cbbae47 Remove all moxie-app references — make guides standalone
All documents now reference only Tauri 2.0 official patterns and APIs
with no dependency on any specific reference application.
2026-05-30 19:29:03 +00:00

21 KiB

WebSocket Server, Client & Script Injection Guide

A comprehensive guide for building native WebSocket servers, frontend clients, and script injection patterns in Tauri 2.0 applications. This guide documents the complete pattern for running a native WebSocket server inside Tauri 2.0, connecting frontend clients, and injecting scripts into external webview windows.


Overview

This guide covers three interrelated patterns for Tauri 2.0 applications:

  1. Native WebSocket Server — A Tokio-backed WebSocket server running inside the Tauri process
  2. Frontend WebSocket Client — JavaScript clients connecting to the local server for bidirectional communication
  3. Script Injection — Injecting JavaScript into dynamically created webview windows (including external URLs) to bridge them into the WebSocket network

Architecture Diagram

┌─────────────────────────────────────────────────────────┐
│                    Tauri Process                         │
│                                                          │
│  ┌──────────────┐     ┌──────────────────────────────┐  │
│  │   lib.rs     │     │    Tokio WebSocket Server     │  │
│  │              │────▶│    (ws://127.0.0.1:8080)     │  │
│  │  .setup()    │     │                               │  │
│  │  spawns      │     │  broadcast channel (16 msg)   │  │
│  │  server      │     │                               │  │
│  └──────────────┘     └───────────┬───────────────────┘  │
│                                   │                       │
│         ┌─────────────────────────┼─────────────────┐    │
│         │                         │                 │    │
│  ┌──────▼──────┐  ┌───────────────▼──────────┐     │    │
│  │ Main Window  │  │  Remote Window             │     │    │
│  │ (index.html) │  │  (external URL)            │     │    │
│  │              │  │  + injected script          │     │    │
│  │ WebSocket   │  │  + WebSocket client         │     │    │
│  │ Client (JS)  │  │  Client (injected JS)       │     │    │
│  └──────┬──────┘  └───────────────┬──────────┘     │    │
└─────────┼─────────────────────────┼────────────────┘    │
          │                         │                       │
          │    WebSocket (local)    │                       │
          └─────────────────────────┘                      │

Part 1: Native WebSocket Server (Rust)

Why Run a WebSocket Server Inside Tauri?

Standard Tauri IPC (invoke, events, channels) only works between Tauri's own windows and its Rust backend. When you need to:

  • Communicate with external applications running on the same machine
  • Bridge external URLs loaded in webview windows back to the Tauri backend
  • Enable cross-window communication at scale (beyond Tauri's event system)
  • Create a local API endpoint for mobile apps or other tools to connect to

...a native WebSocket server running on localhost is the solution.

Dependencies (Cargo.toml)

[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["net", "rt", "sync", "macros"] }
tokio-tungstenite = "0.24"
futures-util = "0.3"

Key dependencies explained:

  • tokio — The async runtime Tauri uses internally. The net feature provides TcpListener, sync provides broadcast channels, rt provides the runtime, and macros provides tokio::select!.
  • tokio-tungstenite — Industry-standard WebSocket implementation for Tokio. Provides accept_async for accepting WS connections and the Message type for frame construction.
  • futures-util — Provides StreamExt (for .next() on streams) and SinkExt (for .send() on sinks).

Server Implementation (src-tauri/src/server.rs)

use tokio::net::TcpListener;
use tokio_tungstenite::accept_async;
use futures_util::stream::StreamExt;
use futures_util::sink::SinkExt;
use tokio::sync::broadcast;

pub async fn start_websocket_server() {
    // Bind to localhost only (security: not exposed to network)
    let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap();
    println!("WebSocket Server listening on ws://127.0.0.1:8080");

    // Broadcast channel: fans out messages to ALL connected clients
    // Queue depth of 16 means if a slow client hasn't read 16 messages,
    // the oldest undelivered message is dropped (lagging policy)
    let (tx, _rx) = broadcast::channel::<String>(16);

    // Accept connections in a loop
    while let Ok((stream, _addr)) = listener.accept().await {
        // Clone the broadcast transmitter for this connection's task
        let tx = tx.clone();
        // Create a dedicated receiver for this connection
        let mut rx = tx.subscribe();

        // Spawn a separate async task per client connection
        tauri::async_runtime::spawn(async move {
            // Perform the WebSocket handshake
            if let Ok(ws_stream) = accept_async(stream).await {
                println!("New WebSocket client connected");
                let (mut ws_sender, mut ws_receiver) = ws_stream.split();

                // Main message loop using tokio::select!
                loop {
                    tokio::select! {
                        // Branch A: Message FROM this specific client
                        incoming = ws_receiver.next() => {
                            match incoming {
                                Some(Ok(msg)) if msg.is_text() => {
                                    let text = msg.to_text().unwrap().to_string();
                                    println!("Received from client: {}", text);

                                    // Broadcast to ALL other connected clients
                                    let _ = tx.send(text);
                                }
                                Some(Ok(msg)) if msg.is_binary() => {
                                    // Handle binary data if needed
                                    let _ = tx.send(format!("[binary:{}bytes]", msg.len()));
                                }
                                _ => {
                                    // None = client disconnected, Err = error
                                    println!("Client disconnected");
                                    break;
                                }
                            }
                        }
                        // Branch B: Broadcast message FROM another client
                        broadcast_msg = rx.recv() => {
                            if let Ok(payload) = broadcast_msg {
                                // Construct a WebSocket text frame and send it
                                let frame = tokio_tungstenite::tungstenite::Message::Text(
                                    payload.into()
                                );
                                if let Err(e) = ws_sender.send(frame).await {
                                    eprintln!("Failed to send to client: {}", e);
                                    break;
                                }
                            }
                        }
                    }
                }
            } else {
                eprintln!("WebSocket handshake failed");
            }
        });
    }
}

How the Broadcast Pattern Works

Client A sends "hello" ─────▶ Rust Server ─────▶ broadcast("hello") ─────▶ Client B receives "hello"
                                                    ─────▶ Client C receives "hello"
                                                    ─────▶ Client D receives "hello"

Client B sends "world" ─────▶ Rust Server ─────▶ broadcast("world") ─────▶ Client A receives "world"
                                                    ─────▶ Client C receives "world"
                                                    ─────▶ Client D receives "world"

Each client:

  1. Gets its own rx = tx.subscribe() receiver
  2. The main loop uses tokio::select! to wait on BOTH incoming messages AND broadcast messages simultaneously
  3. When a client sends a message, it goes through tx.send() which broadcasts to all OTHER clients (the sender's own receiver won't get its own message because subscribe() only receives messages sent after subscription)

Spawning the Server from lib.rs

// src-tauri/src/lib.rs
mod server;

use tauri::Manager;

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_opener::init())
        .setup(|_app| {
            // Spawn the WebSocket server as a background task
            // This runs on Tauri's managed Tokio runtime
            tauri::async_runtime::spawn(server::start_websocket_server());
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![greet])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Key details:

  • tauri::async_runtime::spawn() is used instead of tokio::spawn() to ensure the task runs on Tauri's managed async runtime
  • The server task runs for the entire lifetime of the application
  • The .setup() closure returns immediately — the server runs in the background

Part 2: Frontend WebSocket Client

Main Window Connection (src/main.js)

The main application window connects to the WebSocket server using the standard browser WebSocket API:

const { invoke } = window.__TAURI__.core;

// Connect to the local WebSocket server
const mainSocket = new WebSocket('ws://127.0.0.1:8080');

mainSocket.onopen = () => {
    console.log('Main Control Window connected to the broadcast hub!');
};

mainSocket.onmessage = (event) => {
    const data = JSON.parse(event.data);
    console.log('Received broadcast:', data);

    // Handle specific message types
    if (data.localsession) {
        document.querySelector('#session-display').textContent =
            `Session: ${data.localsession}`;
    }
};

mainSocket.onerror = (err) => {
    console.error('WebSocket connection error:', err);
};

// Sending data to other connected windows
function sendToAllWindows(payload) {
    mainSocket.send(JSON.stringify(payload));
}

// Example: button click sends data
document.querySelector('#broadcast-btn').addEventListener('click', () => {
    sendToAllWindows({
        sender: 'main-window',
        type: 'notification',
        content: 'Hello from the main window!'
    });
});

Message Protocol

While you can send any format over WebSocket, using JSON with a consistent structure is recommended:

// Standard message envelope
{
    "sender": "main-window",        // Origin identifier
    "type": "action-type",           // Message category
    "content": "payload data",       // Message body
    "timestamp": 1234567890          // Optional: for ordering
}

Connection Lifecycle Management

For production apps, handle reconnection and cleanup:

class TauriSocket {
    constructor(url) {
        this.url = url;
        this.ws = null;
        this.reconnectDelay = 1000;
        this.maxReconnectDelay = 30000;
        this.listeners = new Map();
    }

    connect() {
        this.ws = new WebSocket(this.url);

        this.ws.onopen = () => {
            console.log('Connected to local WS server');
            this.reconnectDelay = 1000; // Reset on successful connect
            this.emit('connected');
        };

        this.ws.onmessage = (event) => {
            try {
                const data = JSON.parse(event.data);
                this.emit('message', data);
            } catch (e) {
                console.error('Failed to parse WS message:', e);
            }
        };

        this.ws.onclose = () => {
            console.log('Disconnected, reconnecting...');
            this.emit('disconnected');
            setTimeout(() => this.connect(), this.reconnectDelay);
            this.reconnectDelay = Math.min(
                this.reconnectDelay * 1.5,
                this.maxReconnectDelay
            );
        };

        this.ws.onerror = (err) => {
            console.error('WebSocket error:', err);
        };
    }

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

    on(event, callback) {
        this.listeners.set(event, callback);
    }

    emit(event, data) {
        this.listeners.get(event)?.(data);
    }

    disconnect() {
        this.ws?.close();
    }
}

// Usage
const socket = new TauriSocket('ws://127.0.0.1:8080');
socket.connect();
socket.on('message', (data) => console.log('Got:', data));

Part 3: Script Injection into Webview Windows

The Use Case

When your Tauri app opens windows with external URLs (e.g., a web-based admin panel, a third-party dashboard, or any remote page), those pages have no knowledge of Tauri or your local WebSocket server. Script injection allows you to:

  1. Bridge the external page into your local communication network
  2. Extract data from the page (localStorage, DOM, cookies) and relay it to Tauri
  3. Modify the page (UI changes, injected controls, overlays)
  4. Share sessions between windows (e.g., pass auth tokens)

Injection via initialization_script

The WebviewWindowBuilder::initialization_script() method injects JavaScript that runs when the webview's page loads. Combined with include_str!(), you can load scripts from external files at compile time:

// src-tauri/src/lib.rs
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};

#[tauri::command]
async fn open_remote_session(app_handle: AppHandle, url: String) -> Result<String, String> {
    let target_url = url.parse()
        .map(WebviewUrl::External)
        .map_err(|_| format!("Invalid URL: {}", url))?;

    let window_label = "remote-session";

    // Check if window already exists — focus it instead of duplicating
    if let Some(existing) = app_handle.get_webview_window(window_label) {
        let _ = existing.set_focus();
        return Ok(format!("Focused existing window for {}", url));
    }

    // Load the injection script at compile time
    let script_to_inject = include_str!("../extWebview.js");

    let app_clone = app_handle.clone();
    let url_clone = target_url.clone();

    // Window creation MUST happen on the main thread
    app_handle.run_on_main_thread(move || {
        let _window = WebviewWindowBuilder::new(&app_clone, window_label, url_clone)
            .title("Remote Session")
            .inner_size(1024.0, 768.0)
            .resizable(true)
            .focused(true)
            .initialization_script(script_to_inject)  // ← Inject JS here
            .build();
    }).map_err(|e| format!("Failed to dispatch to main thread: {}", e))?;

    Ok(format!("Dispatched window spawn for: {}", url))
}

The Injected Script (src-tauri/extWebview.js)

// extWebview.js — This script runs automatically when the webview loads
window.addEventListener('DOMContentLoaded', () => {
    console.log('[TAURI] Initializing injected socket link...');

    const ws = new WebSocket('ws://127.0.0.1:8080');

    ws.onopen = () => {
        console.log('[TAURI] Injected socket connected to backend!');

        // Announce this window's identity to the Tauri backend
        const payload = {
            sender: 'remote-webview',
            type: 'identify',
            url: window.location.href,
            title: document.title
        };
        ws.send(JSON.stringify(payload));
    };

    ws.onmessage = (event) => {
        const data = JSON.parse(event.data);
        console.log('[TAURI] RECEIVED FROM BACKEND:', data);

        // Respond with session/token data if requested
        if (data.type === 'request-session') {
            ws.send(JSON.stringify({
                sender: 'remote-webview',
                type: 'session-response',
                localsession: window.localStorage.getItem('auth-token') || 'no-session'
            }));
        }

        // Handle commands from the backend
        if (data.type === 'execute-action') {
            // Perform actions on the remote page as instructed by Tauri
            console.log('[TAURI] Executing action:', data.action);
        }
    };

    ws.onerror = (err) => {
        console.error('[TAURI] Injected socket error:', err);
    };

    // Expose the socket on window for the page's own code to use
    window.localAppSocket = ws;

    // Periodically send status updates
    setInterval(() => {
        if (ws.readyState === WebSocket.OPEN) {
            ws.send(JSON.stringify({
                sender: 'remote-webview',
                type: 'heartbeat',
                url: window.location.href
            }));
        }
    }, 30000);
});

CSP Considerations

Content Security Policy (CSP) can block script injection on external pages. If you need injection on external URLs:

// tauri.conf.json — disable CSP (use with caution!)
{
    "app": {
        "security": {
            "csp": null
        }
    }
}

For better security, craft a specific CSP that allows your injection patterns while blocking other sources:

{
    "app": {
        "security": {
            "csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src ws://127.0.0.1:* http://127.0.0.1:* https:;"
        }
    }
}

Complete Working Example Flow

Step-by-step data flow through the entire system:

1. App starts → lib.rs setup() → spawns WebSocket server on ws://127.0.0.1:8080

2. Main window (index.html) loads → main.js creates WebSocket client
   → connects to ws://127.0.0.1:8080
   → onopen fires → ready to communicate

3. User enters URL and clicks button → main.js calls invoke('greet', { name: url })

4. Rust greet() command:
   → parses URL as WebviewUrl::External
   → checks if window already exists (focus if so)
   → loads extWebview.js via include_str!()
   → dispatches window creation to main thread
   → creates new WebviewWindow with external URL + injected script

5. Remote window loads external page → extWebview.js runs on DOMContentLoaded
   → creates WebSocket client
   → connects to ws://127.0.0.1:8080
   → sends identification message

6. Server broadcasts identification to all clients
   → Main window receives it → displays session info
   → Other remote windows receive it (if any)

7. Main window user clicks "Send" → main.js sends JSON via WebSocket
   → Server broadcasts to all clients
   → All remote windows receive and log the message

Choosing Between Approaches

Requirement Recommended Approach
Connect to external WebSocket server tauri-plugin-websocket (client plugin)
App IS the WebSocket server Native Tokio server (this guide)
Tauri-to-frontend communication Tauri events (emit/listen)
High-throughput streaming data Tauri channels (Channel<T>)
Cross-window messaging (internal) Tauri events or native WS server
External page needs to talk to Tauri Native WS server + script injection
Multiple external pages sharing state Native WS server + script injection

Troubleshooting

"Address already in use" (port 8080)

  • Another process is using port 8080
  • Solution: Change the port in both server.rs and all frontend connection code
  • Or kill the process using port 8080: lsof -i :8080 / netstat -ano | findstr :8080

WebSocket connection fails from injected scripts

  • CSP may be blocking the connection → set "csp": null in config
  • The injected script runs before the page's own scripts → ensure DOM is ready via DOMContentLoaded
  • External HTTPS pages may refuse unencrypted WebSocket connections → page must allow mixed content

Messages not broadcasting to other windows

  • Each client needs its own rx = tx.subscribe() receiver
  • The tx.send() call broadcasts to all subscribers EXCEPT the sender's own subscription
  • Ensure tokio::select! has both branches (incoming AND broadcast)

Window creation fails from async command

  • Window creation MUST happen on the main thread
  • Use app_handle.run_on_main_thread(move || { ... }) for async contexts
  • Not doing this will cause a panic or silent failure