Web Socket

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 merespons 101 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 Authorization atau query parameter sebelum memanggil WebSocketTransformer.upgrade. Setelah upgrade, tidak ada cara mengirim HTTP 401.
  • Cek readyState == WebSocket.open sebelum mengirim — kirim ke socket yang sudah closed akan melempar StateError.
  • WSS (wss://) untuk production — gunakan HttpServer.bindSecure di server dan wss:// di URL client. Plain ws:// hanya untuk development lokal.
  • Tangani close codeonDone memberikan socket.closeCode dan socket.closeReason untuk membedakan disconnect normal (1000) dari error (1011).
  • Bersihkan resource saat onDone — hapus WebSocket dari semua koleksi, cancel timer, dan tutup stream controller yang terkait.

← Sebelumnya: Socket   Berikutnya: Web Server →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact