Web Socket #
WebSocket adalah protokol komunikasi full-duplex di atas TCP yang dimulai dengan HTTP handshake lalu ditingkatkan menjadi koneksi persisten. Berbeda dari HTTP biasa yang request-response, WebSocket memungkinkan server mengirim data ke client kapanpun tanpa harus menunggu request. Ini menjadikannya pilihan ideal untuk fitur real-time: chat, notifikasi live, dashboard yang diperbarui otomatis, dan game multiplayer. Dart mendukung WebSocket melalui dart:io — artikel ini membahas cara membangun server dan client yang andal, lengkap dengan heartbeat, autentikasi, dan reconnect otomatis.
WebSocket vs Alternatif Real-Time #
Sebelum membangun WebSocket, pastikan ini memang pilihan yang tepat untuk kebutuhanmu:
flowchart TD
A{Kebutuhan real-time?} --> B{Server perlu kirim\ndata ke client\ntanpa trigger request?}
B -- Tidak --> C[HTTP biasa + polling\ncukup]
B -- Ya --> D{Komunikasi\ndua arah?}
D -- Tidak sering\nServer → Client saja --> E[Server-Sent Events\nSSE lebih sederhana]
D -- Ya, sering\nbolak-balik --> F{Butuh latensi\nsangat rendah?}
F -- Ya --> G[WebSocket\ndart:io]
F -- Tidak --> H[WebSocket atau\nSSE + HTTP POST]
| Metode | Arah | Latensi | Overhead | Cocok untuk |
|---|---|---|---|---|
| HTTP Polling | Client → Server | Tinggi | Tinggi | Update jarang |
| SSE | Server → Client | Rendah | Rendah | Notifikasi, feed |
| WebSocket | Dua arah | Sangat rendah | Sedang | Chat, game, kolaborasi |
Bagaimana WebSocket Bekerja #
WebSocket dimulai sebagai HTTP request biasa, lalu di-upgrade ke protokol WebSocket:
sequenceDiagram
participant C as Client
participant S as Server
C->>S: HTTP GET /ws\nUpgrade: websocket\nConnection: Upgrade\nSec-WebSocket-Key: ...
S-->>C: HTTP 101 Switching Protocols\nUpgrade: websocket\nSec-WebSocket-Accept: ...
Note over C,S: Koneksi WebSocket aktif — full-duplex
C->>S: Frame: "Halo server"
S-->>C: Frame: "Halo client"
S-->>C: Frame: "Update data"
C->>S: Frame: Ping
S-->>C: Frame: Pong
C->>S: Frame: Close (1000, "Normal closure")
S-->>C: Frame: Close (1000, "OK")
WebSocket Server Dasar #
import 'dart:io';
import 'dart:convert';
Future<void> main() async {
// HttpServer menangani HTTP handshake dan upgrade ke WebSocket
final server = await HttpServer.bind(InternetAddress.anyIPv4, 8080);
print('WebSocket server: ws://localhost:${server.port}/ws');
await for (final request in server) {
if (request.uri.path == '/ws' &&
WebSocketTransformer.isUpgradeRequest(request)) {
// Upgrade koneksi HTTP ke WebSocket
final socket = await WebSocketTransformer.upgrade(request);
_tanganiKlien(socket, request);
} else {
// Tolak request yang bukan WebSocket upgrade
request.response
..statusCode = HttpStatus.badRequest
..write('Hanya koneksi WebSocket yang diterima')
..close();
}
}
}
void _tanganiKlien(WebSocket socket, HttpRequest request) {
final alamat = request.connectionInfo?.remoteAddress.address ?? 'unknown';
print('Client terhubung: $alamat');
socket.listen(
(pesan) {
print('Diterima dari $alamat: $pesan');
// Kirim balik (echo)
socket.add('Echo: $pesan');
},
onError: (error) {
print('Error dari $alamat: $error');
},
onDone: () {
print('Client $alamat terputus (close code: ${socket.closeCode})');
},
cancelOnError: true,
);
}
Message Envelope — Format Pesan Terstruktur #
Untuk server yang menangani berbagai jenis pesan, gunakan format envelope JSON agar server bisa tahu tipe pesan dan merutekannya ke handler yang tepat:
import 'dart:convert';
import 'dart:io';
// Definisi tipe pesan
enum TipePesan { chat, notifikasi, status, error, ping, pong }
// Envelope standar untuk semua pesan
class Pesan {
final TipePesan tipe;
final Map<String, dynamic> data;
final String? dari;
final DateTime waktu;
Pesan({
required this.tipe,
required this.data,
this.dari,
DateTime? waktu,
}) : waktu = waktu ?? DateTime.now().toUtc();
factory Pesan.dariJson(String json) {
final map = jsonDecode(json) as Map<String, dynamic>;
return Pesan(
tipe: TipePesan.values.byName(map['tipe'] as String),
data: map['data'] as Map<String, dynamic>,
dari: map['dari'] as String?,
waktu: DateTime.parse(map['waktu'] as String),
);
}
String keJson() => jsonEncode({
'tipe': tipe.name,
'data': data,
'dari': dari,
'waktu': waktu.toIso8601String(),
});
}
// Handler berdasarkan tipe pesan
void prosesPesan(WebSocket socket, String rawPesan, String idKlien) {
try {
final pesan = Pesan.dariJson(rawPesan);
switch (pesan.tipe) {
case TipePesan.chat:
_prosesChat(socket, pesan, idKlien);
case TipePesan.ping:
_balasPong(socket);
case TipePesan.status:
_updateStatus(socket, pesan, idKlien);
default:
socket.add(Pesan(
tipe: TipePesan.error,
data: {'pesan': 'Tipe pesan tidak dikenal: ${pesan.tipe.name}'},
).keJson());
}
} on FormatException {
socket.add(Pesan(
tipe: TipePesan.error,
data: {'pesan': 'Format JSON tidak valid'},
).keJson());
}
}
void _balasPong(WebSocket socket) {
socket.add(Pesan(
tipe: TipePesan.pong,
data: {'waktuServer': DateTime.now().toUtc().toIso8601String()},
).keJson());
}
Server Chat dengan Rooms #
Pola rooms/channels memungkinkan pesan hanya dikirim ke subset client yang bergabung ke room tertentu:
import 'dart:io';
import 'dart:convert';
class ServerChat {
// Map dari roomId ke Set WebSocket yang bergabung
final Map<String, Set<WebSocket>> _rooms = {};
// Map dari WebSocket ke info client
final Map<WebSocket, Map<String, String>> _infoKlien = {};
void _bergabungRoom(WebSocket socket, String roomId, String namaUser) {
_rooms.putIfAbsent(roomId, () => {}).add(socket);
_infoKlien[socket] = {'room': roomId, 'nama': namaUser};
// Beritahu semua di room bahwa ada yang bergabung
_broadcastKeRoom(roomId, jsonEncode({
'tipe': 'sistem',
'pesan': '$namaUser bergabung ke room $roomId',
'jumlahOnline': _rooms[roomId]!.length,
}), kecuali: socket);
print('$namaUser bergabung ke room $roomId');
}
void _keluar(WebSocket socket) {
final info = _infoKlien.remove(socket);
if (info == null) return;
final roomId = info['room']!;
final nama = info['nama']!;
_rooms[roomId]?.remove(socket);
if (_rooms[roomId]?.isEmpty ?? false) {
_rooms.remove(roomId); // bersihkan room kosong
}
_broadcastKeRoom(roomId, jsonEncode({
'tipe': 'sistem',
'pesan': '$nama meninggalkan room',
'jumlahOnline': _rooms[roomId]?.length ?? 0,
}));
}
void _broadcastKeRoom(String roomId, String pesan, {WebSocket? kecuali}) {
final anggota = _rooms[roomId] ?? {};
for (final socket in anggota) {
if (socket != kecuali && socket.readyState == WebSocket.open) {
try {
socket.add(pesan);
} catch (e) {
// Client mungkin sudah disconnected
}
}
}
}
void tanganiKlien(WebSocket socket, HttpRequest request) {
socket.listen(
(pesan) {
try {
final data = jsonDecode(pesan as String) as Map<String, dynamic>;
final tipe = data['tipe'] as String;
switch (tipe) {
case 'bergabung':
_bergabungRoom(
socket,
data['room'] as String,
data['nama'] as String,
);
case 'chat':
final info = _infoKlien[socket];
if (info != null) {
_broadcastKeRoom(info['room']!, jsonEncode({
'tipe': 'chat',
'dari': info['nama'],
'pesan': data['pesan'],
'waktu': DateTime.now().toUtc().toIso8601String(),
}));
}
case 'daftarRoom':
socket.add(jsonEncode({
'tipe': 'daftarRoom',
'rooms': _rooms.map((k, v) => MapEntry(k, v.length)),
}));
}
} on FormatException {
socket.add(jsonEncode({'tipe': 'error', 'pesan': 'JSON tidak valid'}));
}
},
onDone: () => _keluar(socket),
onError: (_) => _keluar(socket),
cancelOnError: true,
);
}
}
Future<void> main() async {
final server = ServerChat();
final httpServer = await HttpServer.bind(InternetAddress.anyIPv4, 8080);
await for (final request in httpServer) {
if (WebSocketTransformer.isUpgradeRequest(request)) {
final socket = await WebSocketTransformer.upgrade(request);
server.tanganiKlien(socket, request);
}
}
}
Heartbeat — Menjaga Koneksi Tetap Hidup #
Koneksi WebSocket yang idle bisa diputus oleh proxy, load balancer, atau firewall setelah beberapa menit. Implementasikan heartbeat ping-pong untuk mendeteksi koneksi mati dan memastikan koneksi tetap aktif:
import 'dart:async';
import 'dart:io';
class KoneksiWebSocket {
final WebSocket _socket;
Timer? _pingTimer;
Timer? _pongTimeout;
bool _menunggoPong = false;
static const _intervalPing = Duration(seconds: 30);
static const _timeoutPong = Duration(seconds: 10);
KoneksiWebSocket(this._socket) {
_mulaiHeartbeat();
}
void _mulaiHeartbeat() {
_pingTimer = Timer.periodic(_intervalPing, (_) => _kirimPing());
}
void _kirimPing() {
if (_socket.readyState != WebSocket.open) {
_hentikanHeartbeat();
return;
}
_menunggoPong = true;
_socket.add('__ping__'); // kirim ping
// Jika tidak ada pong dalam 10 detik, anggap koneksi mati
_pongTimeout = Timer(_timeoutPong, () {
if (_menunggoPong) {
print('Client tidak merespons ping — menutup koneksi');
_socket.close(WebSocketStatus.goingAway, 'Ping timeout');
}
});
}
void prosesData(dynamic pesan) {
if (pesan == '__pong__') {
_menunggoPong = false;
_pongTimeout?.cancel();
return;
}
if (pesan == '__ping__') {
_socket.add('__pong__');
return;
}
// Proses pesan bisnis biasa
_prosesPesan(pesan);
}
void _hentikanHeartbeat() {
_pingTimer?.cancel();
_pongTimeout?.cancel();
}
void tutup([int code = WebSocketStatus.normalClosure, String reason = '']) {
_hentikanHeartbeat();
_socket.close(code, reason);
}
void _prosesPesan(dynamic pesan) {
// implementasi handler pesan
}
}
WebSocket Client dengan Reconnect Otomatis #
Client yang andal harus bisa menghubungkan ulang saat koneksi terputus:
import 'dart:io';
import 'dart:async';
import 'dart:convert';
class KlienWebSocket {
final String url;
WebSocket? _socket;
bool _harusTerhubung = true;
int _percobaan = 0;
final _controllerPesan = StreamController<dynamic>.broadcast();
Stream<dynamic> get pesan => _controllerPesan.stream;
KlienWebSocket(this.url);
Future<void> hubungkan() async {
_harusTerhubung = true;
await _cobaTerhubung();
}
Future<void> _cobaTerhubung() async {
while (_harusTerhubung) {
try {
_percobaan++;
print('Menghubungkan ke $url (percobaan $_percobaan)...');
_socket = await WebSocket.connect(url).timeout(
const Duration(seconds: 10),
);
_percobaan = 0; // reset counter setelah berhasil
print('Terhubung ke $url');
// Listen pesan
await for (final pesan in _socket!) {
_controllerPesan.add(pesan);
}
// Socket ditutup (onDone)
if (_harusTerhubung) {
print('Koneksi terputus — mencoba reconnect...');
}
} on SocketException catch (e) {
print('Gagal terhubung: $e');
} on TimeoutException {
print('Timeout saat menghubungkan');
}
if (!_harusTerhubung) break;
// Exponential backoff: 1, 2, 4, 8, 16 detik (maksimal)
final tunda = Duration(seconds: (1 << _percobaan.clamp(0, 4)));
print('Reconnect dalam ${tunda.inSeconds}s...');
await Future.delayed(tunda);
}
}
void kirim(String pesan) {
if (_socket?.readyState == WebSocket.open) {
_socket!.add(pesan);
} else {
throw StateError('WebSocket tidak terhubung');
}
}
void kirimJson(Map<String, dynamic> data) {
kirim(jsonEncode(data));
}
Future<void> putuskan() async {
_harusTerhubung = false;
await _socket?.close(WebSocketStatus.normalClosure, 'Client disconnect');
await _controllerPesan.close();
}
}
// Penggunaan
Future<void> main() async {
final klien = KlienWebSocket('ws://localhost:8080/ws');
klien.pesan.listen((pesan) {
print('Pesan dari server: $pesan');
});
await klien.hubungkan();
klien.kirimJson({'tipe': 'bergabung', 'room': 'umum', 'nama': 'Budi'});
klien.kirimJson({'tipe': 'chat', 'pesan': 'Halo semua!'});
}
Autentikasi WebSocket #
WebSocket tidak punya mekanisme autentikasi bawaan. Dua cara umum:
Via Query Parameter (sederhana) #
// Client: ws://localhost:8080/ws?token=eyJhbGciOi...
Future<void> main() async {
final server = await HttpServer.bind(InternetAddress.anyIPv4, 8080);
await for (final request in server) {
if (!WebSocketTransformer.isUpgradeRequest(request)) continue;
// Ambil token dari query string
final token = request.uri.queryParameters['token'];
if (token == null || !await verifikasiToken(token)) {
request.response
..statusCode = HttpStatus.unauthorized
..write('Token tidak valid')
..close();
continue;
}
final idPengguna = ambilIdDariToken(token);
final socket = await WebSocketTransformer.upgrade(request);
_tanganiKlienTerautentikasi(socket, idPengguna);
}
}
Via Header Authorization (lebih aman)
#
// Client mengirim: Authorization: Bearer eyJhbGciOi...
await for (final request in server) {
if (!WebSocketTransformer.isUpgradeRequest(request)) continue;
final auth = request.headers.value(HttpHeaders.authorizationHeader);
if (auth == null || !auth.startsWith('Bearer ')) {
request.response.statusCode = HttpStatus.unauthorized;
await request.response.close();
continue;
}
final token = auth.substring(7); // hapus "Bearer "
if (!await verifikasiToken(token)) {
request.response.statusCode = HttpStatus.forbidden;
await request.response.close();
continue;
}
final socket = await WebSocketTransformer.upgrade(request);
// Lanjutkan...
}
WebSocket Secure (WSS) #
Untuk production, selalu gunakan wss:// (WebSocket over TLS):
import 'dart:io';
// Server WSS
Future<void> serverWSS() async {
final konteks = SecurityContext()
..useCertificateChain('server.crt')
..usePrivateKey('server.key');
// HttpServer.bindSecure = HTTPS, upgrade ke WSS
final server = await HttpServer.bindSecure(
InternetAddress.anyIPv4,
443,
konteks,
);
print('WSS server: wss://localhost:443/ws');
await for (final request in server) {
if (WebSocketTransformer.isUpgradeRequest(request)) {
final socket = await WebSocketTransformer.upgrade(request);
// tangani seperti biasa
}
}
}
// Client WSS
Future<void> clientWSS() async {
// wss:// otomatis menggunakan TLS
final socket = await WebSocket.connect('wss://api.example.com/ws');
socket.listen((data) => print('Data: $data'));
}
Close Code — Menutup dengan Bermakna #
WebSocket mendefinisikan kode penutup standar yang mengkomunikasikan alasan disconnect:
// WebStatus codes yang umum digunakan
class WebSocketStatus {
static const int normalClosure = 1000; // penutupan normal
static const int goingAway = 1001; // server restart / client navigasi
static const int protocolError = 1002; // pelanggaran protokol
static const int unsupportedData = 1003; // tipe data tidak didukung
static const int normalClosure = 1000;
static const int internalServerError = 1011; // error tidak terduga di server
}
// Menutup dengan code dan reason yang bermakna
void tutupDenganAlasan(WebSocket socket, String alasan) {
socket.close(WebSocketStatus.normalClosure, alasan);
}
// Di server: tangani close code dari client
socket.listen(
(_) {},
onDone: () {
final code = socket.closeCode;
final reason = socket.closeReason;
print('Ditutup dengan kode $code: $reason');
if (code == WebSocketStatus.goingAway) {
// Client browser navigasi ke halaman lain — normal
} else if (code == WebSocketStatus.internalServerError) {
// Ada error di client — log untuk investigasi
_log.error('Client error: $reason');
}
},
);
Anti-Pattern WebSocket #
Global List tanpa Cleanup #
// ANTI-PATTERN: List global yang tidak dibersihkan
final List<WebSocket> semua = [];
void tambah(WebSocket ws) => semua.add(ws);
void broadcast(String pesan) {
for (final ws in semua) {
ws.add(pesan); // ✗ bisa throw jika ws sudah closed
}
}
// ✗ ws yang sudah disconnect tidak pernah dihapus — memory leak!
// BENAR: hapus saat done, cek readyState sebelum kirim
void broadcast(String pesan) {
semua.removeWhere((ws) => ws.readyState != WebSocket.open);
for (final ws in semua) {
try {
ws.add(pesan);
} catch (_) { /* diabaikan */ }
}
}
Tidak Menangani Error Saat Send #
// ANTI-PATTERN: send tanpa error handling
void kirimKeSemuaKlien(String pesan) {
for (final ws in klien) {
ws.add(pesan); // ✗ throw StateError jika ws sudah closed
}
}
// BENAR: cek readyState dan tangani exception
void kirimKeSemuaKlien(String pesan) {
for (final ws in klien.toList()) { // .toList() agar aman saat modifikasi
if (ws.readyState == WebSocket.open) {
try {
ws.add(pesan);
} catch (e) {
print('Gagal kirim ke client: $e');
klien.remove(ws);
}
} else {
klien.remove(ws);
}
}
}
Ringkasan #
- WebSocket adalah upgrade dari HTTP — dimulai dengan HTTP GET yang berisi header
Upgrade: websocket, lalu server merespons101 Switching Protocols.WebSocketTransformer.upgrade(request)menangani ini secara otomatis.- Gunakan message envelope JSON — bungkus semua pesan dalam struktur
{tipe, data, waktu}agar server bisa merutekan ke handler yang tepat berdasarkan tipe pesan.- Rooms/channels untuk aplikasi multi-room — simpan client di
Map<String, Set<WebSocket>>, broadcast hanya ke anggota room yang sama.- Heartbeat ping-pong wajib untuk koneksi long-lived — kirim ping setiap 30 detik, tutup koneksi jika tidak ada pong dalam 10 detik. Tanpa ini, koneksi mati mungkin tidak terdeteksi selama menit-menit.
- Reconnect dengan exponential backoff di client — tunggu 1, 2, 4, 8 detik sebelum mencoba lagi, bukan langsung retry berulang kali.
- Autentikasi sebelum upgrade — verifikasi token di header
Authorizationatau query parameter sebelum memanggilWebSocketTransformer.upgrade. Setelah upgrade, tidak ada cara mengirim HTTP 401.- Cek
readyState == WebSocket.opensebelum mengirim — kirim ke socket yang sudah closed akan melemparStateError.- WSS (
wss://) untuk production — gunakanHttpServer.bindSecuredi server danwss://di URL client. Plainws://hanya untuk development lokal.- Tangani close code —
onDonememberikansocket.closeCodedansocket.closeReasonuntuk membedakan disconnect normal (1000) dari error (1011).- Bersihkan resource saat
onDone— hapus WebSocket dari semua koleksi, cancel timer, dan tutup stream controller yang terkait.