local_websocket 0.0.3
local_websocket: ^0.0.3 copied to clipboard
A pure Dart library for local network WebSocket communication with automatic server discovery and scanning capabilities.
Local WebSocket #
local_websocket is a local-first WebSocket library for Flutter and Dart that enables automatic device discovery and real-time communication on a local network (LAN) without cloud servers, static IPs, or manual configuration. It is designed for offline-friendly, zero-config applications where devices need to find and communicate with each other over Wi-Fi or private networks.
This package is useful for Flutter and Dart developers building local network applications such as mobile-to-desktop companion apps, local multiplayer games, classroom or lab tools, kiosk systems, medical or industrial devices, and offline or air-gapped environments. If all participating devices are connected to the same local network, local_websocket provides a simple and lightweight solution.
The package combines three core capabilities in a single API: automatic local network discovery, a built-in WebSocket server and client, and real-time messaging using Dart streams. Clients can discover available servers on the local subnet, connect without hardcoded IP addresses, exchange messages in real time, and attach metadata such as device name, role, or user information. The library is written in pure Dart, has zero external dependencies, and works across Flutter and Dart targets including mobile and desktop platforms.
Table of Contents #
- Features
- Installation
- Quick Start
- Core Concepts
- Detailed Guide
- Use Cases
- Examples
- Architecture
- Best Practices
- Troubleshooting
- Contributing
- License
Features #
- π Automatic Server Discovery - Scan local networks to find WebSocket servers automatically
- π Easy Server Setup - Create WebSocket servers with minimal configuration
- π± Client Connection Management - Simple client connection and real-time messaging
- π Auto-Reconnect - Automatic reconnection with exponential/linear backoff strategies
- π Cross-Platform - Works on all Dart platforms (Flutter, CLI, Desktop, Server)
- π§ Zero Dependencies - Pure Dart implementation using only dart:io, dart:async, and dart:convert
- π¬ Broadcast & Echo Modes - Choose between broadcasting to all clients or echoing back to sender
- π·οΈ Metadata Support - Attach custom details to servers and clients
- π‘ Real-time Streaming - Reactive streams for messages, connections, and client updates
- π Unique Client IDs - Automatic unique ID generation for each client
- π‘οΈ Type-Safe - Fully typed API with Dart's null safety
- π Flexible Authentication - Extensible delegate-based authentication system (token, header, IP-based)
- β Request & Message Validation - Validate requests, clients, and messages with custom delegates
- π Connection Lifecycle Hooks - Handle client connection and disconnection events
- β‘ Lightweight - No external dependencies means smaller package size and faster installation
Installation #
Add local_websocket to your pubspec.yaml:
dependencies:
local_websocket: ^0.0.2
Then run:
dart pub get
Or for Flutter projects:
flutter pub get
Quick Start #
1. Create a WebSocket Server #
import 'package:local_websocket/local_websocket.dart';
void main() async {
// Create server with custom details
final server = Server(
echo: false, // Broadcast mode: messages go to all OTHER clients
details: {
'name': 'My Local Server',
'description': 'A local WebSocket server',
},
);
// Start server on localhost:8080
await server.start('127.0.0.1', port: 8080);
print('Server running at ${server.address}');
// Listen for client connections
server.clientsStream.listen((clients) {
print('Connected clients: ${clients.length}');
});
}
2. Discover Servers on Network #
import 'package:local_websocket/local_websocket.dart';
void main() async {
// Scan localhost for servers on port 8080
// Returns a Stream that continuously scans every 3 seconds
await for (final servers in Scanner.scan('localhost', port: 8080)) {
print('Found ${servers.length} servers:');
for (final server in servers) {
print('- ${server.path}');
print(' Details: ${server.details}');
}
}
}
3. Connect Client and Send Messages #
import 'package:local_websocket/local_websocket.dart';
void main() async {
// Create client with metadata
final client = Client(
details: {
'username': 'john_doe',
'device': 'mobile',
},
);
// Connect to server
await client.connect('ws://127.0.0.1:8080/ws');
print('Connected! Client ID: ${client.uid}');
// Listen for messages
client.messageStream.listen((message) {
print('Received: $message');
});
// Listen for connection changes
client.connectionStream.listen((isConnected) {
print('Connection status: ${isConnected ? "Connected" : "Disconnected"}');
});
// Send messages (supports String, Map, List, etc.)
client.send('Hello, server!');
client.send({'type': 'chat', 'message': 'Hello from client'});
client.send(['data', 123, true]);
// Disconnect when done
await Future.delayed(Duration(seconds: 5));
await client.disconnect();
}
Core Concepts #
Server #
The Server is the central hub that accepts WebSocket connections and manages message routing between clients. It runs on a specified host and port, provides server information via HTTP, and handles WebSocket connections.
Two Messaging Modes:
- Broadcast Mode (
echo: false): Messages from one client are sent to all OTHER clients (sender doesn't receive their own message) - Echo Mode (
echo: true): Messages from one client are sent to ALL clients including the sender
Client #
The Client connects to a server via WebSocket and can send/receive messages in real-time. Each client has a unique ID (timestamp-based) and can include custom metadata (username, device type, etc.) that gets passed to the server as query parameters.
Scanner #
The Scanner automatically discovers servers on the local network by scanning IP addresses in a subnet. It checks each IP for the server's HTTP endpoint and validates it's a local-websocket server by checking the Server header.
DiscoveredServer #
A simple model representing a discovered server with:
path: The WebSocket URL (e.g.,ws://192.168.1.100:8080/ws)details: The server's metadata (returned from the HTTP endpoint)
Detailed Guide #
Server #
Creating a Server
final server = Server(
echo: false, // Broadcast mode
details: {
'name': 'Game Server',
'maxPlayers': 4,
'gameType': 'multiplayer',
},
// Optional: Add authentication
requestAuthenticationDelegate: RequestTokenAuthenticator(
validTokens: {'secret123', 'secret456'},
),
// Optional: Handle client connections
clientConnectionDelegate: MyConnectionHandler(),
);
Starting the Server
// Start on localhost (127.0.0.1)
await server.start('127.0.0.1', port: 8080);
// Start on all network interfaces (0.0.0.0)
await server.start('0.0.0.0', port: 8080);
// Start on specific IP address
await server.start('192.168.1.100', port: 9000);
Important:
127.0.0.1(localhost): Only accessible from the same machine0.0.0.0: Accessible from any network interface- Specific IP: Accessible via that IP address
Server Properties
server.isConnected; // bool: Is server running?
server.address; // Uri: Server address (throws if not running)
server.clients; // Set<Client>: Currently connected clients
server.clientsStream; // Stream<Set<Client>>: Stream of client changes
server.connectionStream; // Stream<bool>: Stream of server connection status
server.messageStream; // Stream<dynamic>: Stream of all messages received
Sending Messages from Server
// Send to all connected clients
server.send('Server announcement!');
server.send({'type': 'notification', 'message': 'New player joined'});
Stopping the Server
await server.stop();
// Automatically disconnects all clients
Server HTTP Endpoint
When running, the server provides an HTTP endpoint at the root path (/) that returns server details as JSON:
curl http://127.0.0.1:8080/
# Response: {"name":"Game Server","maxPlayers":4,"gameType":"multiplayer"}
This endpoint includes a custom header: Server: local-websocket/1.0.0, which the Scanner uses for discovery.
Client #
Creating a Client
final client = Client(
details: {
'username': 'Alice',
'deviceType': 'iOS',
'appVersion': '1.0.0',
},
);
Note: The details map values must be String type. They're passed to the server as query parameters.
Connecting to a Server
// Connect using discovered server
final servers = await Scanner.scan('localhost').first;
await client.connect(servers.first.path);
// Or connect directly
await client.connect('ws://127.0.0.1:8080/ws');
The client details are automatically added as query parameters:
ws://127.0.0.1:8080/ws?username=Alice&deviceType=iOS&appVersion=1.0.0
Client Properties
client.uid; // String: Unique ID for this client (timestamp-based)
client.details; // Map<String, String>: Client metadata (unmodifiable)
client.isConnected; // bool: Is client connected?
client.messageStream; // Stream<dynamic>: Incoming messages
client.connectionStream; // Stream<bool>: Connection status changes
Sending Messages
// Send string
client.send('Hello!');
// Send JSON-encodable data
client.send({'action': 'move', 'x': 10, 'y': 20});
// Send list
client.send([1, 2, 3, 4, 5]);
Receiving Messages
client.messageStream.listen((message) {
// Messages arrive as dynamic - cast as needed
if (message is String) {
print('Text message: $message');
} else if (message is Map) {
print('JSON message: $message');
}
});
Monitoring Connection Status
client.connectionStream.listen((isConnected) {
if (isConnected) {
print('Connected to server');
} else {
print('Disconnected from server');
}
});
Disconnecting
await client.disconnect();
Auto-Reconnect
The client supports automatic reconnection when the connection is lost unexpectedly. Enable it by providing a ClientReconectionDelegate:
final client = Client(
details: {'username': 'Alice'},
clientReconnectionDelegate: ExponentialBackoffReconnect(
maxAttempts: 5,
initialDelay: Duration(seconds: 1),
maxDelay: Duration(seconds: 30),
multiplier: 2.0,
),
);
await client.connect('ws://127.0.0.1:8080/ws');
// If connection is lost, client will automatically try to reconnect
// with exponential backoff: 1s, 2s, 4s, 8s, 16s (capped at 30s)
Built-in Reconnection Strategies:
-
ExponentialBackoffReconnect - Exponentially increasing delays (1s, 2s, 4s, 8s...)
ExponentialBackoffReconnect( maxAttempts: 5, // Stop after 5 failed attempts initialDelay: Duration(seconds: 1), maxDelay: Duration(seconds: 30), multiplier: 2.0, ) -
LinearBackoffReconnect - Linearly increasing delays (2s, 4s, 6s, 8s...)
LinearBackoffReconnect( maxAttempts: 10, // Stop after 10 failed attempts interval: Duration(seconds: 2), ) -
InfiniteReconnect - Never gives up, keeps trying forever
InfiniteReconnect( interval: Duration(seconds: 5), // Try every 5 seconds )
Connection Status Tracking:
Monitor the connection status to show UI feedback:
client.connectionStream.listen((status) {
switch (status) {
case ClientConnectionStatus.connected:
print('β
Connected');
break;
case ClientConnectionStatus.connecting:
print('β³ Connecting... (auto-reconnect in progress)');
break;
case ClientConnectionStatus.disconnected:
print('β Disconnected');
break;
}
});
// Or check current status
if (client.connectionStatus == ClientConnectionStatus.connected) {
client.send('Hello!');
}
Custom Reconnection Logic:
Create your own reconnection strategy:
class SmartReconnect implements ClientReconectionDelegate {
@override
Future<bool> shouldReconnect(int attemptNumber, Duration timeSinceLastConnect) async {
// Only reconnect during business hours
final hour = DateTime.now().hour;
if (hour < 9 || hour > 17) return false;
return attemptNumber < 3;
}
@override
Future<Duration> getReconnectDelay(int attemptNumber) async {
return Duration(seconds: 5);
}
@override
void onReconnected(int attemptNumber) {
print('Successfully reconnected!');
}
@override
void onReconnectFailed(int totalAttempts) {
print('Failed to reconnect after $totalAttempts attempts');
}
}
final client = Client(
clientReconnectionDelegate: SmartReconnect(),
);
Scanner #
Basic Scanning
// Scan localhost continuously (every 3 seconds)
await for (final servers in Scanner.scan('localhost')) {
print('Found ${servers.length} servers');
}
Scanning a Subnet
// Scan 192.168.1.0-255 subnet on port 8080
await for (final servers in Scanner.scan('192.168.1')) {
for (final server in servers) {
print('Server at ${server.path}');
print('Details: ${server.details}');
}
}
Custom Port and Interval
// Scan every 5 seconds on port 9000
await for (final servers in Scanner.scan(
'192.168.1',
port: 9000,
interval: Duration(seconds: 5),
)) {
// Handle discovered servers
}
One-Time Scan
// Get first scan result and stop
final servers = await Scanner.scan('localhost').first;
print('Found ${servers.length} servers');
Scanner Parameters
Scanner.scan(
String host, // 'localhost', '127.0.0.1', or '192.168.1'
{
int port = 8080, // Port to scan
Duration interval = const Duration(seconds: 3), // Scan interval
String type = 'local-websocket', // Server type identifier
}
)
Host Formats:
'localhost'or'127.0.0.1': Scans127.0.0.0-255'192.168.1': Scans192.168.1.0-255- Must be at least 3 parts when using IP format
Delegates #
The package provides a powerful delegate system for customizing server behavior. Delegates allow you to add authentication, validation, and event handling without modifying the core server logic.
Overview #
There are four types of delegates:
- RequestAuthenticationDelegate - Authenticate HTTP requests before WebSocket upgrade
- ClientValidationDelegate - Validate clients after WebSocket connection established
- ClientConnectionDelegate - Handle client connection/disconnection events
- MessageValidationDelegate - Validate individual messages from clients
RequestAuthenticationDelegate #
Authenticates incoming HTTP requests before the WebSocket upgrade occurs. This is your first line of defense for security.
Interface
abstract interface class RequestAuthenticationDelegate {
FutureOr<RequestAuthenticationResult> authenticateRequest(HttpRequest request);
}
Built-in Authenticators
1. RequestTokenAuthenticator
Validates a token passed as a query parameter:
final server = Server(
requestAuthenticationDelegate: RequestTokenAuthenticator(
validTokens: {'secret123', 'admin_token', 'user_pass'},
parameterName: 'token', // Default parameter name
),
);
Clients must include the token in the connection URL:
await client.connect('ws://127.0.0.1:8080/ws?token=secret123');
Or use the details parameter:
final client = Client(details: {'token': 'secret123'});
await client.connect('ws://127.0.0.1:8080/ws');
2. RequestHeaderAuthenticator
Validates HTTP headers (useful for authorization tokens):
final server = Server(
requestAuthenticationDelegate: RequestHeaderAuthenticator(
headerName: 'Authorization',
validValues: {'Bearer secret123', 'Bearer admin_token'},
caseSensitive: true, // Default is true
),
);
Note: WebSocket clients from browsers cannot set custom headers during initial handshake. This is best used for server-to-server communication or non-browser clients.
3. RequestIPAuthenticator
Restricts access based on IP address whitelist:
final server = Server(
requestAuthenticationDelegate: RequestIPAuthenticator(
allowedIPs: {
'127.0.0.1', // Localhost
'192.168.1.100', // Specific device
'10.0.0.50', // Another device
},
),
);
4. MultiRequestAuthenticator
Combines multiple authenticators - all must pass:
final server = Server(
requestAuthenticationDelegate: MultiRequestAuthenticator([
RequestTokenAuthenticator(validTokens: {'secret123'}),
RequestIPAuthenticator(allowedIPs: {'127.0.0.1', '192.168.1.100'}),
]),
);
In this example, clients must provide a valid token AND connect from an allowed IP.
Custom Authentication
Create your own authenticator by implementing the interface:
class CustomAuthenticator implements RequestAuthenticationDelegate {
final String apiKey;
const CustomAuthenticator({required this.apiKey});
@override
Future<RequestAuthenticationResult> authenticateRequest(HttpRequest request) async {
final providedKey = request.url.queryParameters['api_key'];
if (providedKey == null) {
return RequestAuthenticationResult.failure(
reason: 'Missing API key',
statusCode: 401,
);
}
// Validate against database, external API, etc.
final isValid = await validateApiKey(providedKey);
if (!isValid) {
return RequestAuthenticationResult.failure(
reason: 'Invalid API key',
statusCode: 403,
);
}
return RequestAuthenticationResult.success(
metadata: {'apiKey': providedKey},
);
}
Future<bool> validateApiKey(String key) async {
// Your validation logic here
return key == apiKey;
}
}
// Usage
final server = Server(
requestAuthenticationDelegate: CustomAuthenticator(apiKey: 'my-secret-key'),
);
RequestAuthenticationResult
The result object provides factory methods for common responses:
// Success - allow connection
RequestAuthenticationResult.success(metadata: {'user': 'john'});
// Success - simple allow
RequestAuthenticationResult.allow();
// Failure - custom reason and status code
RequestAuthenticationResult.failure(
reason: 'Invalid credentials',
statusCode: 403,
);
// Failure - simple deny
RequestAuthenticationResult.deny();
ClientValidationDelegate #
Validates clients after the WebSocket connection is established but before they're added to the active clients list.
Interface
abstract class ClientValidationDelegate {
FutureOr<bool> validateClient(Client client, HttpRequest request);
}
Use Cases
- Check if client details are valid
- Enforce maximum client limits
- Validate client metadata
- Check client against database
Example: Limit Maximum Clients
class MaxClientsValidator implements ClientValidationDelegate {
final int maxClients;
final Server server;
MaxClientsValidator({required this.maxClients, required this.server});
@override
Future<bool> validateClient(Client client, HttpRequest request) async {
if (server.clients.length >= maxClients) {
print('Server full: ${server.clients.length}/$maxClients');
return false;
}
return true;
}
}
// Usage
final server = Server();
server.clientValidationDelegate = MaxClientsValidator(
maxClients: 10,
server: server,
);
Example: Require Username
class UsernameValidator implements ClientValidationDelegate {
@override
Future<bool> validateClient(Client client, HttpRequest request) async {
final username = client.details['username'];
if (username == null || username.isEmpty) {
print('Client rejected: missing username');
return false;
}
if (username.length < 3) {
print('Client rejected: username too short');
return false;
}
return true;
}
}
// Usage
final server = Server(
clientValidationDelegate: UsernameValidator(),
);
// Client must provide username
final client = Client(details: {'username': 'Alice'});
await client.connect('ws://127.0.0.1:8080/ws');
ClientConnectionDelegate #
Handles client connection and disconnection events. Perfect for logging, analytics, or triggering side effects.
Interface
abstract interface class ClientConnectionDelegate {
FutureOr<void> onClientConnected(Client client);
FutureOr<void> onClientDisconnected(Client client);
}
Example: Connection Logging
class ConnectionLogger implements ClientConnectionDelegate {
@override
Future<void> onClientConnected(Client client) async {
print('β
Client connected: ${client.uid}');
print(' Details: ${client.details}');
// Log to database, send analytics, etc.
await logConnection(client.uid, client.details);
}
@override
Future<void> onClientDisconnected(Client client) async {
print('β Client disconnected: ${client.uid}');
// Cleanup, save state, etc.
await cleanupClientData(client.uid);
}
Future<void> logConnection(String uid, Map<String, String> details) async {
// Your logging logic
}
Future<void> cleanupClientData(String uid) async {
// Your cleanup logic
}
}
// Usage
final server = Server(
clientConnectionDelegate: ConnectionLogger(),
);
Example: Broadcast Join/Leave Messages
class JoinLeaveAnnouncer implements ClientConnectionDelegate {
final Server server;
JoinLeaveAnnouncer({required this.server});
@override
Future<void> onClientConnected(Client client) async {
final username = client.details['username'] ?? 'Anonymous';
server.send({
'type': 'user_joined',
'username': username,
'timestamp': DateTime.now().toIso8601String(),
});
}
@override
Future<void> onClientDisconnected(Client client) async {
final username = client.details['username'] ?? 'Anonymous';
server.send({
'type': 'user_left',
'username': username,
'timestamp': DateTime.now().toIso8601String(),
});
}
}
// Usage
final server = Server();
server.clientConnectionDelegate = JoinLeaveAnnouncer(server: server);
MessageValidationDelegate #
Validates individual messages from clients before broadcasting. This is useful for content filtering, rate limiting, or message format validation.
Interface
abstract class MessageValidationDelegate {
FutureOr<bool> validateMessage(Client client, String message);
}
Example: Profanity Filter
class ProfanityFilter implements MessageValidationDelegate {
final Set<String> bannedWords = {'badword1', 'badword2'};
@override
Future<bool> validateMessage(Client client, String message) async {
final lowerMessage = message.toString().toLowerCase();
for (final word in bannedWords) {
if (lowerMessage.contains(word)) {
print('Blocked message from ${client.uid}: contains profanity');
return false;
}
}
return true;
}
}
// Usage
final server = Server(
messageValidationDelegate: ProfanityFilter(),
);
Example: Rate Limiting
class RateLimiter implements MessageValidationDelegate {
final Map<String, List<DateTime>> _messageTimes = {};
final int maxMessages;
final Duration timeWindow;
RateLimiter({
this.maxMessages = 10,
this.timeWindow = const Duration(seconds: 10),
});
@override
Future<bool> validateMessage(Client client, String message) async {
final now = DateTime.now();
final clientId = client.uid;
// Initialize or get message times for this client
_messageTimes.putIfAbsent(clientId, () => []);
// Remove old messages outside time window
_messageTimes[clientId]!.removeWhere(
(time) => now.difference(time) > timeWindow,
);
// Check if limit exceeded
if (_messageTimes[clientId]!.length >= maxMessages) {
print('Rate limit exceeded for ${client.uid}');
return false;
}
// Add this message
_messageTimes[clientId]!.add(now);
return true;
}
}
// Usage
final server = Server(
messageValidationDelegate: RateLimiter(
maxMessages: 5,
timeWindow: Duration(seconds: 10),
),
);
Example: Message Format Validation
class MessageFormatValidator implements MessageValidationDelegate {
@override
Future<bool> validateMessage(Client client, String message) async {
// Expecting JSON messages
try {
final decoded = jsonDecode(message);
if (decoded is! Map) {
print('Invalid message format: not a map');
return false;
}
if (!decoded.containsKey('type')) {
print('Invalid message format: missing type field');
return false;
}
return true;
} catch (e) {
print('Invalid message format: not valid JSON');
return false;
}
}
}
// Usage
final server = Server(
messageValidationDelegate: MessageFormatValidator(),
);
Combining Multiple Delegates #
You can use all delegates together for comprehensive control:
final server = Server(
echo: false,
details: {'name': 'Secure Chat Server'},
// 1. Authenticate requests
requestAuthenticationDelegate: MultiRequestAuthenticator([
RequestTokenAuthenticator(validTokens: {'secret123'}),
RequestIPAuthenticator(allowedIPs: {'127.0.0.1'}),
]),
// 2. Validate clients
clientValidationDelegate: UsernameValidator(),
// 3. Handle connections
clientConnectionDelegate: JoinLeaveAnnouncer(server: server),
// 4. Validate messages
messageValidationDelegate: MultiMessageValidator([
ProfanityFilter(),
RateLimiter(maxMessages: 5, timeWindow: Duration(seconds: 10)),
MessageFormatValidator(),
]),
);
Note: Create a MultiMessageValidator similar to MultiRequestAuthenticator if you need to combine multiple message validators.
Delegate Execution Order #
When a client attempts to connect, delegates are executed in this order:
-
RequestAuthenticationDelegate - Before WebSocket upgrade
- If fails: Returns HTTP 401/403, connection rejected
-
WebSocket Upgrade - Connection established
-
ClientValidationDelegate - After connection, before adding to clients
- If fails: WebSocket closed with code 1008, client not added
-
Client Added - Client joins the active clients set
-
ClientConnectionDelegate.onClientConnected - After client added
- Runs asynchronously, doesn't block
-
Message Loop - For each message:
- MessageValidationDelegate - Validate message
- If valid: Broadcast to clients
- If invalid: Silently drop message
-
On Disconnect:
- ClientConnectionDelegate.onClientDisconnected - Cleanup
- Runs asynchronously, doesn't block
Security Best Practices #
- Always use RequestAuthenticationDelegate for authentication (happens before WebSocket upgrade)
- Use ClientValidationDelegate for business logic validation (max clients, metadata checks)
- Use MessageValidationDelegate for content filtering and rate limiting
- Use HTTPS/WSS in production - These delegates don't replace transport security
- Validate all input - Don't trust client data
- Log authentication failures - Monitor for attacks
- Use IP whitelisting carefully - IPs can be spoofed on some networks
Use Cases #
1. Local Multiplayer Games #
Create real-time multiplayer games where players on the same WiFi network can discover and join games.
// Game host creates server
final server = Server(details: {'gameName': 'Chess Match', 'players': 0});
await server.start('0.0.0.0');
// Players scan and join
final games = await Scanner.scan('192.168.1').first;
final client = Client(details: {'playerName': 'Alice'});
await client.connect(games.first.path);
2. LAN Chat Application #
Build a local chat room for devices on the same network.
// Chat server
final server = Server(echo: false, details: {'room': 'General'});
server.messageStream.listen((msg) => print('Message: $msg'));
// Chat client
final client = Client(details: {'username': 'Bob'});
client.messageStream.listen((msg) => print('New message: $msg'));
client.send('Hello everyone!');
3. Device Synchronization #
Sync data between multiple devices without cloud services.
// Device A (server)
final serverDevice = Server(details: {'deviceName': 'Desktop'});
await serverDevice.start('0.0.0.0');
// Device B (client)
final clientDevice = Client(details: {'deviceName': 'Laptop'});
await clientDevice.connect(discoveredServer.path);
clientDevice.send({'syncData': [...]}); // Share data
4. IoT Device Discovery #
Discover and communicate with IoT devices on the local network.
// IoT device runs server
final iotServer = Server(details: {
'deviceType': 'SmartLight',
'firmwareVersion': '2.0.1',
});
// Control app scans for devices
await for (final devices in Scanner.scan('192.168.1')) {
for (final device in devices) {
if (device.details['deviceType'] == 'SmartLight') {
// Connect and control
}
}
}
5. File Sharing #
Share files between devices on the same network.
// Sender
final sender = Server(details: {'sharing': 'documents.pdf'});
server.messageStream.listen((request) {
// Send file chunks
server.send(fileData);
});
// Receiver
final receiver = Client();
receiver.messageStream.listen((chunk) {
// Receive and reconstruct file
});
6. Resilient Mobile App #
Build a mobile app that maintains connection despite network issues.
// Mobile client with auto-reconnect
final client = Client(
details: {'userId': '12345', 'deviceType': 'mobile'},
clientReconnectionDelegate: ExponentialBackoffReconnect(
maxAttempts: 10,
initialDelay: Duration(seconds: 1),
maxDelay: Duration(seconds: 60),
),
);
await client.connect('ws://192.168.1.100:8080/ws');
// Monitor connection status for UI feedback
client.connectionStream.listen((status) {
if (status == ClientConnectionStatus.connecting) {
showSnackBar('Reconnecting...');
} else if (status == ClientConnectionStatus.connected) {
showSnackBar('Connected!');
}
});
// Client will automatically reconnect if WiFi drops or server restarts
Examples #
Complete Chat Application #
import 'package:local_websocket/local_websocket.dart';
void main() async {
print('Choose mode: (1) Server or (2) Client');
// In real app, get user input
final mode = 1; // Example: server mode
if (mode == 1) {
// Server mode
final server = Server(
echo: false,
details: {'chatRoom': 'Main Lobby'},
);
await server.start('0.0.0.0', port: 8080);
print('Chat server started at ${server.address}');
server.clientsStream.listen((clients) {
print('Users online: ${clients.length}');
});
server.messageStream.listen((message) {
print('Message received: $message');
});
} else {
// Client mode
print('Scanning for chat servers...');
final servers = await Scanner.scan('192.168.1').first;
if (servers.isEmpty) {
print('No servers found');
return;
}
final client = Client(details: {'username': 'Alice'});
await client.connect(servers.first.path);
print('Connected to chat!');
client.messageStream.listen((message) {
print('Message: $message');
});
// Send messages
client.send('Hello everyone!');
}
}
Real-time Game Example #
import 'package:local_websocket/local_websocket.dart';
class GameServer {
final server = Server(
echo: false,
details: {
'gameName': 'Tic Tac Toe',
'maxPlayers': 2,
'status': 'waiting',
},
);
Future<void> start() async {
await server.start('0.0.0.0', port: 8080);
server.messageStream.listen((message) {
if (message is Map) {
handleGameAction(message);
}
});
}
void handleGameAction(Map action) {
// Process game logic
final response = {'type': 'gameUpdate', 'board': [...]};
server.send(response); // Broadcast to all players
}
}
class GameClient {
final client = Client(details: {'playerName': 'Bob'});
Future<void> join() async {
final games = await Scanner.scan('192.168.1').first;
final gameServer = games.firstWhere(
(s) => s.details['gameName'] == 'Tic Tac Toe',
);
await client.connect(gameServer.path);
client.messageStream.listen((message) {
if (message is Map && message['type'] == 'gameUpdate') {
updateGameBoard(message['board']);
}
});
}
void makeMove(int x, int y) {
client.send({'type': 'move', 'x': x, 'y': y});
}
void updateGameBoard(dynamic board) {
// Update UI
}
}
Architecture #
Zero-Dependency Implementation #
This package is built using only Dart SDK libraries with zero external dependencies:
dart:io- HTTP server, WebSocket protocol, network operationsdart:async- Streams, futures, and async operationsdart:convert- JSON encoding/decoding
Benefits:
- β Smaller package size (~50KB vs typical 2MB+ with dependencies)
- β Faster installation and pub get
- β No dependency conflicts
- β Direct control over WebSocket implementation
- β Works everywhere Dart runs without platform-specific code
How It Works #
βββββββββββββββββββ
β Server β
β (0.0.0.0:8080) β
ββββββββββ¬βββββββββ
β
β HTTP GET / β Returns server details (JSON)
β WS /ws β WebSocket endpoint
β
ββββββ΄βββββ
β β
βββββΌββββ ββββΌβββββ
βClient1β βClient2β
βββββββββ βββββββββ
Server Responsibilities:
- Listen for HTTP requests at
/(returns server details) - Accept WebSocket connections at
/ws - Manage connected clients
- Route messages between clients (broadcast or echo)
- Emit streams for clients, connections, and messages
Client Responsibilities:
- Connect to server's WebSocket endpoint
- Send messages to server
- Receive messages from server
- Emit streams for messages and connection status
- Include metadata via query parameters
Scanner Responsibilities:
- Generate IP addresses in subnet range
- Send HTTP GET requests to each IP
- Check
Serverheader forlocal-websocket - Parse response JSON for server details
- Return list of discovered servers
- Repeat scan at specified intervals
Best Practices #
1. Error Handling #
Always wrap server/client operations in try-catch:
try {
await server.start('0.0.0.0', port: 8080);
} catch (e) {
print('Failed to start server: $e');
}
try {
await client.connect('ws://127.0.0.1:8080/ws');
} catch (e) {
print('Failed to connect: $e');
}
2. Resource Cleanup #
Always clean up resources when done:
// Stop server
await server.stop();
// Disconnect clients
await client.disconnect();
// Cancel stream subscriptions
subscription.cancel();
3. Message Validation #
Validate incoming messages:
client.messageStream.listen((message) {
if (message is! Map) {
print('Invalid message format');
return;
}
if (!message.containsKey('type')) {
print('Message missing type field');
return;
}
// Process valid message
});
4. Connection State Management #
Track connection state:
bool isConnected = false;
client.connectionStream.listen((connected) {
isConnected = connected;
if (!connected) {
// Handle disconnection, try reconnect
}
});
5. Scanning Optimization #
Don't scan continuously if you don't need to:
// One-time scan
final servers = await Scanner.scan('192.168.1').first;
// Limited scanning
final subscription = Scanner.scan('192.168.1').listen((servers) {
if (servers.isNotEmpty) {
subscription.cancel(); // Stop scanning once found
}
});
6. Server Details Best Practices #
Include useful metadata:
final server = Server(
details: {
'name': 'My Server',
'version': '1.0.0',
'maxClients': 10,
'requiresAuth': false,
'description': 'A friendly server',
},
);
Troubleshooting #
Problem: Scanner doesn't find any servers #
Solutions:
- Verify server is running: Check
server.isConnected - Check firewall: Ensure port is not blocked
- Verify correct subnet: Use
ipconfig(Windows) orifconfig(Mac/Linux) to find your subnet - Check port: Ensure scanner and server use the same port
- Wait longer: Scanner needs time to check all IPs
// Debug: Check if server is accessible via HTTP
final response = await http.get(Uri.parse('http://127.0.0.1:8080/'));
print(response.body); // Should print server details
Problem: Client can't connect #
Solutions:
- Verify WebSocket URL format: Must start with
ws:// - Check server address: Use IP address instead of hostname
- Ensure server is running on correct interface (use
0.0.0.0for all interfaces)
// Correct format
await client.connect('ws://192.168.1.100:8080/ws');
// Incorrect formats
await client.connect('http://192.168.1.100:8080/ws'); // Wrong scheme
await client.connect('192.168.1.100:8080/ws'); // Missing scheme
Problem: Messages not being received #
Solutions:
- Check echo mode: If
echo: false, sender won't receive their own messages - Verify message format: Ensure messages are JSON-encodable
- Check stream subscriptions: Ensure you're listening to
messageStream
// Always listen BEFORE sending
client.messageStream.listen((msg) => print(msg));
await Future.delayed(Duration(milliseconds: 100)); // Let subscription establish
client.send('Hello');
Problem: StateError when starting/stopping #
Solutions:
- Don't start an already running server
- Don't stop an already stopped server
- Check
isConnectedbefore operations
if (!server.isConnected) {
await server.start('0.0.0.0');
}
if (server.isConnected) {
await server.stop();
}
Problem: Port already in use #
Solutions:
- Use different port
- Stop other application using the port
- Wait a few seconds after stopping server before restarting
await server.start('0.0.0.0', port: 8081); // Try different port
Contributing #
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
License #
This project is licensed under the MIT License - see the LICENSE file for details.
Author #
Created by Ehsan Rashidi
Support #
If you find this package helpful, please give it a βοΈ on GitHub!
DiscoveredServer #
String path // WebSocket connection path
Map<String, dynamic> details // Server details/metadata
Network Scanning #
The scanner automatically detects servers by:
- HTTP Header Detection - Looks for
Server: local-websocket/*header - Response Validation - Verifies JSON response format
- Subnet Resolution - Automatically resolves network subnets
Supported Host Formats #
// Localhost scanning
await Scanner.scan('localhost'); // Scans 127.0.0.*
await Scanner.scan('127.0.0.1'); // Scans 127.0.0.*
// Network subnet scanning
await Scanner.scan('192.168.1'); // Scans 192.168.1.*
await Scanner.scan('10.0.0'); // Scans 10.0.0.*
Error Handling #
The package uses a structured error model with WebSocketError for consistent error handling.
WebSocketError #
All connection and authentication errors are wrapped in a WebSocketError that provides:
code: Error category ('AUTHENTICATION_FAILED','CONNECTION_FAILED','VALIDATION_FAILED')message: Human-readable error descriptionstatusCode: HTTP status code when applicable (401, 403, etc.)details: Additional error metadataoriginalError: Underlying exception for debugging
Client Connection Errors #
try {
final client = Client(details: {'token': 'secret123'});
await client.connect('ws://127.0.0.1:8080/ws');
} on WebSocketError catch (e) {
if (e.code == 'AUTHENTICATION_FAILED') {
if (e.statusCode == 401) {
print('Authentication required: ${e.message}');
// Prompt user for credentials
} else if (e.statusCode == 403) {
print('Invalid credentials: ${e.message}');
// Show error message to user
}
} else if (e.code == 'VALIDATION_FAILED') {
print('Connection rejected: ${e.message}');
// Handle validation failure (e.g., banned client)
} else if (e.code == 'CONNECTION_FAILED') {
print('Connection failed: ${e.message}');
// Check network, server address, etc.
}
} catch (e) {
print('Unexpected error: $e');
}
Server Errors #
try {
final server = Server();
await server.start('0.0.0.0', port: 8080);
} on StateError catch (e) {
print('State error: $e');
// Server already running
} on SocketException catch (e) {
print('Socket error: $e');
// Port already in use, invalid host, etc.
} catch (e) {
print('Network error: $e');
}
Common Error Scenarios #
Authentication Failures
// HTTP 401 - Missing credentials
WebSocketError: [AUTHENTICATION_FAILED] Authentication required (HTTP 401)
// HTTP 403 - Invalid credentials
WebSocketError: [AUTHENTICATION_FAILED] Invalid token (HTTP 403)
Connection Failures
// Network unreachable
WebSocketError: [CONNECTION_FAILED] Connection failed: Network unreachable
// Server not responding
WebSocketError: [CONNECTION_FAILED] Connection timeout
Validation Failures
// Client validation failed
WebSocketError: [VALIDATION_FAILED] Maximum clients reached
// Message validation failed (silently dropped by server)