# WebSocket Server, Client & Script Injection Guide > Based on the working `moxie-app` reference implementation in this repository. > 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 used in the reference `moxie-app`: 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 ```text ┌─────────────────────────────────────────────────────────┐ │ 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`) ```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`) ```rust 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::(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 ```text 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` ```rust // 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: ```javascript 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: ```javascript // 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: ```javascript 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: ```rust // src-tauri/src/lib.rs use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; #[tauri::command] async fn open_remote_session(app_handle: AppHandle, url: String) -> Result { 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`) ```javascript // 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: ```json // 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: ```json { "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: ```text 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`) | | 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