Web Socket

Web Socket #

WebSocket adalah protokol yang mengubah cara aplikasi web berkomunikasi secara fundamental. HTTP tradisional bersifat request-response — klien meminta, server menjawab, koneksi selesai. WebSocket membuka saluran permanen antara klien dan server: setelah koneksi terbentuk, kedua pihak bisa mengirim data kapan saja tanpa harus memulai request baru. Ini adalah fondasi dari aplikasi real-time modern — chat langsung, notifikasi push, dashboard yang update otomatis, kolaborasi dokumen, game online, hingga feed harga saham. Di Ruby, ada beberapa pilihan: Faye::WebSocket yang berjalan di atas EventMachine, gem websocket-driver yang lebih fleksibel, atau ActionCable yang terintegrasi penuh dengan Rails.

HTTP vs WebSocket — Perbedaan Fundamental #

sequenceDiagram
    participant B as Browser
    participant S as Server

    note over B,S: HTTP Tradisional — Request/Response
    B->>S: GET /data HTTP/1.1
    S->>B: 200 OK + data
    note over B,S: Koneksi selesai

    B->>S: GET /data HTTP/1.1 (polling)
    S->>B: 200 OK + data (mungkin tidak ada yang baru)
    note over B,S: Polling = boros bandwidth

    note over B,S: WebSocket — Saluran Permanen
    B->>S: HTTP Upgrade Request
    S->>B: 101 Switching Protocols
    note over B,S: Koneksi WebSocket terbuka
    S->>B: Data (kapan saja, tanpa request)
    B->>S: Data (kapan saja, dua arah)
    S->>B: Notifikasi real-time
    B->>S: Perintah dari user
    note over B,S: Koneksi tetap terbuka
HTTP Polling vs WebSocket:
  HTTP Polling:
  ✗ Klien harus terus-menerus tanya "ada data baru?"
  ✗ Overhead HTTP per request (headers, handshake TCP)
  ✗ Latency tinggi — data baru harus tunggu polling berikutnya
  ✗ Server dibebankan oleh request yang hasilnya sering kosong

  WebSocket:
  ✓ Server bisa push data kapan saja tanpa diminta
  ✓ Overhead minimal setelah koneksi terbuka
  ✓ Latency sangat rendah — data langsung dikirim
  ✓ Satu koneksi TCP yang bertahan lama
  ✗ Koneksi persistent mengonsumsi resource server
  ✗ Lebih kompleks dari HTTP sederhana

Protokol WebSocket — Cara Kerjanya #

WebSocket dimulai sebagai koneksi HTTP biasa, kemudian di-upgrade melalui mekanisme yang disebut handshake:

KLIEN → SERVER (HTTP Upgrade Request):
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

SERVER → KLIEN (HTTP 101 Switching Protocols):
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Setelah ini: koneksi menjadi WebSocket, HTTP tidak lagi digunakan.
Data dikirim dalam frame biner, bukan HTTP.

Setelah handshake, data dikirim dalam frame — unit terkecil komunikasi WebSocket yang berisi informasi apakah ini teks atau biner, apakah ini frame terakhir dari pesan, dan data aktualnya.


Implementasi Manual WebSocket dari TCP #

Untuk memahami cara kerja WebSocket dari bawah, kita bisa implementasikan handshake dan framing secara manual:

require 'socket'
require 'digest'
require 'base64'

WEBSOCKET_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"

def buat_accept_key(client_key)
  Base64.strict_encode64(
    Digest::SHA1.digest(client_key.strip + WEBSOCKET_MAGIC)
  )
end

def lakukan_handshake(client)
  request = ""
  while (baris = client.gets) && baris != "\r\n"
    request += baris
  end

  # Ambil WebSocket key dari header
  kunci = request.match(/Sec-WebSocket-Key: (.+)/)[1]&.strip
  accept = buat_accept_key(kunci)

  # Kirim respons upgrade
  client.write [
    "HTTP/1.1 101 Switching Protocols",
    "Upgrade: websocket",
    "Connection: Upgrade",
    "Sec-WebSocket-Accept: #{accept}",
    "",
    ""
  ].join("\r\n")

  puts "Handshake WebSocket berhasil"
end

def baca_frame(client)
  byte1 = client.getbyte
  return nil if byte1.nil?

  byte2 = client.getbyte
  masked  = (byte2 & 0x80) != 0
  panjang = byte2 & 0x7F

  # Handle extended payload length
  panjang = case panjang
            when 126 then client.read(2).unpack1("n")
            when 127 then client.read(8).unpack1("Q>")
            else panjang
            end

  mask = masked ? client.read(4).bytes : nil
  data = client.read(panjang).bytes

  # Unmask data
  if masked
    data = data.each_with_index.map { |byte, i| byte ^ mask[i % 4] }
  end

  data.pack("C*").force_encoding("UTF-8")
end

def kirim_frame(client, pesan)
  data = pesan.encode("UTF-8").bytes
  panjang = data.length

  frame = [0x81]   # FIN bit + opcode teks (0x1)

  if panjang <= 125
    frame << panjang
  elsif panjang <= 65535
    frame << 126
    frame += [panjang].pack("n").bytes
  else
    frame << 127
    frame += [panjang].pack("Q>").bytes
  end

  frame += data
  client.write(frame.pack("C*"))
end

# Server WebSocket manual
server = TCPServer.new("localhost", 8080)
puts "WebSocket server manual berjalan di ws://localhost:8080"

loop do
  client = server.accept
  Thread.new(client) do |conn|
    begin
      lakukan_handshake(conn)
      kirim_frame(conn, "Selamat datang di WebSocket server manual!")

      loop do
        pesan = baca_frame(conn)
        break if pesan.nil?
        puts "Diterima: #{pesan}"
        kirim_frame(conn, "Echo: #{pesan}")
      end
    rescue => e
      puts "Error: #{e.message}"
    ensure
      conn.close
    end
  end
end
Implementasi manual di atas berguna untuk memahami protokol, tapi untuk produksi selalu gunakan library yang sudah teruji seperti websocket-driver atau faye-websocket. Protokol WebSocket punya banyak edge case (fragmented frames, ping/pong, close handshake) yang sulit diimplementasikan dengan benar dari nol.

Faye::WebSocket — Server WebSocket Standalone #

faye-websocket adalah gem yang matang untuk WebSocket di luar Rails, berjalan di atas EventMachine (event loop non-blocking):

gem install faye-websocket eventmachine

Echo Server dengan Faye #

# websocket_server.rb
require 'faye/websocket'
require 'eventmachine'
require 'json'

EM.run do
  klien = {}   # ws => { id:, nama: }

  app = lambda do |env|
    # Cek apakah ini request WebSocket
    unless Faye::WebSocket.websocket?(env)
      return [200, {"Content-Type" => "text/plain"}, ["Bukan WebSocket request"]]
    end

    ws = Faye::WebSocket.new(env)

    ws.on :open do |event|
      id = SecureRandom.hex(4)
      klien[ws] = { id: id, nama: "User-#{id}" }
      puts "Klien terhubung: #{id}"
      ws.send(JSON.generate({ tipe: "sambutan", id: id, pesan: "Selamat datang!" }))
    end

    ws.on :message do |event|
      data = JSON.parse(event.data) rescue { "tipe" => "teks", "pesan" => event.data }
      pengirim = klien[ws]

      puts "[#{pengirim[:nama]}] #{data['pesan']}"

      # Broadcast ke semua klien yang terhubung
      broadcast = JSON.generate({
        tipe:      "pesan",
        pengirim:  pengirim[:nama],
        pesan:     data["pesan"],
        waktu:     Time.now.strftime("%H:%M:%S")
      })

      klien.each_key { |klien_ws| klien_ws.send(broadcast) }
    end

    ws.on :close do |event|
      info = klien.delete(ws)
      puts "Klien terputus: #{info&.dig(:nama)} (#{event.code}: #{event.reason})"
      ws = nil
    end

    ws.on :error do |event|
      puts "Error: #{event.message}"
    end

    # Penting: kembalikan response objek async
    ws.rack_response
  end

  # Jalankan dengan Thin atau Puma
  Rack::Server.start(app: app, port: 8080, server: "thin")
end
# Jalankan server
ruby websocket_server.rb

# Atau dengan thin langsung
thin start -R websocket_server.rb -p 8080

Klien WebSocket dengan Faye #

# websocket_client.rb
require 'faye/websocket'
require 'eventmachine'
require 'json'

EM.run do
  ws = Faye::WebSocket::Client.new("ws://localhost:8080")

  ws.on :open do |event|
    puts "Terhubung ke server!"
    ws.send(JSON.generate({ tipe: "pesan", pesan: "Halo dari klien Ruby!" }))
  end

  ws.on :message do |event|
    data = JSON.parse(event.data)
    puts "[#{data['pengirim'] || 'Server'}] #{data['pesan']}"
  end

  ws.on :close do |event|
    puts "Terputus: #{event.code} - #{event.reason}"
    EM.stop
  end

  # Kirim pesan setiap 3 detik
  EM.add_periodic_timer(3) do
    ws.send(JSON.generate({ tipe: "ping", pesan: "Ping #{Time.now.to_i}" }))
  end
end

ActionCable — WebSocket Terintegrasi Rails #

ActionCable adalah solusi WebSocket bawaan Rails yang mengintegrasikan real-time communication dengan ekosistem Rails — model, job, dan broadcasting — secara seamless.

Arsitektur ActionCable #

flowchart TD
    A[Browser / Klien] --> B[ActionCable Connection]
    B --> C[Channel Subscription]
    C --> D[ChatChannel]
    C --> E[NotificationChannel]
    D --> F[ActiveRecord Model]
    D --> G["ActionCable.server.broadcast"]
    G --> H[Pub/Sub Backend]
    H --> I["Redis (Production)"]
    H --> J["Async (Development)"]
    I --> G2[Broadcast ke semua subscriber]
    G2 --> A

Setup ActionCable #

ActionCable sudah tersedia di Rails 5+ tanpa instalasi tambahan. Konfigurasi minimal:

# config/cable.yml
development:
  adapter: async          # in-memory, hanya untuk satu server

test:
  adapter: test

production:
  adapter: redis
  url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
  channel_prefix: myapp_production   # hindari konflik jika pakai Redis bersama
# config/routes.rb
Rails.application.routes.draw do
  mount ActionCable.server => "/cable"   # endpoint WebSocket
  # ... route lain
end

Connection — Autentikasi WebSocket #

ApplicationCable::Connection adalah pintu gerbang — di sinilah autentikasi dilakukan sebelum koneksi diterima:

# app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user   # identifier unik per koneksi

    def connect
      self.current_user = temukan_user_terverifikasi
    end

    private

    def temukan_user_terverifikasi
      # Cari user dari session cookie
      if (user_id = cookies.encrypted[:user_id])
        User.find_by(id: user_id) || reject_unauthorized_connection
      # Atau dari JWT token di query string
      elsif (token = request.params[:token])
        verifikasi_jwt(token) || reject_unauthorized_connection
      else
        reject_unauthorized_connection
      end
    end

    def verifikasi_jwt(token)
      payload = JWT.decode(token, Rails.application.secret_key_base).first
      User.find_by(id: payload["user_id"])
    rescue JWT::DecodeError
      nil
    end
  end
end

Channel — Logika Bisnis Real-time #

# app/channels/chat_channel.rb
class ChatChannel < ApplicationCable::Channel
  # Dipanggil saat klien subscribe ke channel ini
  def subscribed
    room = params[:room]
    reject unless room.present?

    # Verifikasi user boleh masuk ruangan ini
    @ruangan = Room.find_by(slug: room)
    reject unless @ruangan && current_user.bisa_masuk?(@ruangan)

    # Subscribe ke stream broadcasting untuk ruangan ini
    stream_for @ruangan   # otomatis buat stream name dari model
    # atau: stream_from "chat:#{@ruangan.id}"

    # Notifikasi ke channel bahwa user bergabung
    ActionCable.server.broadcast(
      "chat:#{@ruangan.id}",
      { tipe: "bergabung", pengguna: current_user.nama, waktu: Time.now }
    )
  end

  # Dipanggil saat klien unsubscribe atau disconnect
  def unsubscribed
    if @ruangan
      ActionCable.server.broadcast(
        "chat:#{@ruangan.id}",
        { tipe: "keluar", pengguna: current_user.nama, waktu: Time.now }
      )
    end
  end

  # Dipanggil dari klien: App.chatChannel.perform('kirim_pesan', {teks: '...'})
  def kirim_pesan(data)
    teks = data["teks"]&.strip
    return unless teks.present?
    return if teks.length > 500

    pesan = @ruangan.pesan.create!(
      pengguna: current_user,
      teks:     teks
    )

    # Broadcast ke semua subscriber ruangan
    ActionCable.server.broadcast(
      "chat:#{@ruangan.id}",
      {
        tipe:       "pesan_baru",
        id:         pesan.id,
        pengguna:   current_user.nama,
        avatar_url: current_user.avatar_url,
        teks:       pesan.teks,
        waktu:      pesan.created_at.strftime("%H:%M")
      }
    )
  end

  # Typing indicator — tidak perlu simpan ke DB
  def sedang_mengetik
    ActionCable.server.broadcast(
      "chat:#{@ruangan.id}",
      { tipe: "mengetik", pengguna: current_user.nama }
    )
  end
end

Broadcasting dari Mana Saja #

Salah satu kekuatan ActionCable adalah kamu bisa broadcast dari mana saja — model callback, background job, controller:

# Dari model callback (misalnya setelah pesanan dibuat)
class Order < ApplicationRecord
  after_create_commit :broadcast_pesanan_baru

  private

  def broadcast_pesanan_baru
    ActionCable.server.broadcast(
      "dashboard_admin",
      {
        tipe:   "pesanan_baru",
        id:     self.id,
        total:  self.total,
        status: self.status
      }
    )
  end
end

# Dari background job (Sidekiq/ActiveJob)
class NotifikasiJob < ApplicationJob
  def perform(user_id, pesan)
    ActionCable.server.broadcast(
      "notifikasi_user_#{user_id}",
      { tipe: "notifikasi", pesan: pesan, waktu: Time.now }
    )
  end
end

# Dari controller
class OrdersController < ApplicationController
  def update
    @order.update!(status: params[:status])

    # Broadcast update ke dashboard
    ActionCable.server.broadcast(
      "order_#{@order.id}",
      { status: @order.status, updated_at: @order.updated_at }
    )

    render json: @order
  end
end

Klien JavaScript ActionCable #

// app/javascript/channels/chat_channel.js
import consumer from "./consumer"

let chatChannel = null

function bergabungKeRuangan(slugRuangan) {
  // Unsubscribe dari ruangan sebelumnya jika ada
  chatChannel?.unsubscribe()

  chatChannel = consumer.subscriptions.create(
    { channel: "ChatChannel", room: slugRuangan },
    {
      connected() {
        console.log("Terhubung ke ruangan:", slugRuangan)
        tampilkanStatus("Terhubung")
      },

      disconnected() {
        console.log("Terputus dari ruangan:", slugRuangan)
        tampilkanStatus("Terputus — mencoba reconnect...")
      },

      received(data) {
        switch (data.tipe) {
          case "pesan_baru":
            tampilkanPesan(data)
            break
          case "bergabung":
            tampilkanSistem(`${data.pengguna} bergabung ke ruangan`)
            break
          case "keluar":
            tampilkanSistem(`${data.pengguna} keluar dari ruangan`)
            break
          case "mengetik":
            tampilkanIndikatorMengetik(data.pengguna)
            break
        }
      },

      // Method yang bisa dipanggil dari kode lain
      kirimPesan(teks) {
        this.perform("kirim_pesan", { teks })
      },

      sedangMengetik() {
        this.perform("sedang_mengetik")
      }
    }
  )
}

// Contoh penggunaan
bergabungKeRuangan("umum")

document.getElementById("form-pesan").addEventListener("submit", (e) => {
  e.preventDefault()
  const input = document.getElementById("input-pesan")
  chatChannel.kirimPesan(input.value)
  input.value = ""
})

document.getElementById("input-pesan").addEventListener("input", () => {
  chatChannel.sedangMengetik()
})

Heartbeat — Menjaga Koneksi Tetap Hidup #

Koneksi WebSocket bisa terputus karena proxy atau firewall yang menutup koneksi idle. Heartbeat ping/pong mencegah ini:

# ActionCable sudah punya heartbeat bawaan
# Konfigurasi interval (default 3 detik):
ActionCable.server.config.ping_interval = 3

# Untuk server Faye/custom, implementasi manual:
EM.add_periodic_timer(30) do
  klien.each_key do |ws|
    ws.ping("heartbeat") do |berhasil|
      unless berhasil
        puts "Klien tidak merespons ping, tutup koneksi"
        ws.close
      end
    end
  end
end
// Klien JavaScript — reconnect otomatis
class WebSocketReliable {
  constructor(url) {
    this.url = url
    this.reconnectDelay = 1000   // mulai dari 1 detik
    this.maxDelay = 30000        // maksimal 30 detik
    this.connect()
  }

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

    this.ws.onopen = () => {
      console.log("WebSocket terhubung")
      this.reconnectDelay = 1000   // reset delay saat berhasil
    }

    this.ws.onmessage = (event) => {
      this.onmessage(JSON.parse(event.data))
    }

    this.ws.onclose = (event) => {
      if (!event.wasClean) {
        console.log(`Terputus, mencoba reconnect dalam ${this.reconnectDelay}ms`)
        setTimeout(() => this.connect(), this.reconnectDelay)
        // Exponential backoff
        this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxDelay)
      }
    }

    this.ws.onerror = (error) => {
      console.error("WebSocket error:", error)
    }
  }

  send(data) {
    if (this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(data))
    } else {
      console.warn("Tidak bisa kirim, WebSocket belum terbuka")
    }
  }

  onmessage(data) {
    // Override di subkelas atau instance
    console.log("Pesan diterima:", data)
  }
}

Notifikasi Real-time — Use Case Umum #

Pola umum untuk sistem notifikasi per-user dengan ActionCable:

# app/channels/notification_channel.rb
class NotificationChannel < ApplicationCable::Channel
  def subscribed
    stream_for current_user   # stream khusus per user
  end

  def unsubscribed; end

  def tandai_terbaca(data)
    notif = current_user.notifications.find(data["id"])
    notif.update!(terbaca: true)
  end
end

# Helper module untuk broadcast notifikasi ke user
module NotifikasiHelper
  def self.kirim_ke(user, pesan:, tipe: :info, link: nil)
    notif = user.notifications.create!(
      pesan: pesan,
      tipe:  tipe,
      link:  link
    )

    NotificationChannel.broadcast_to(
      user,
      {
        id:     notif.id,
        pesan:  pesan,
        tipe:   tipe,
        link:   link,
        waktu:  notif.created_at.strftime("%H:%M")
      }
    )
  end
end

# Penggunaan dari mana saja
NotifikasiHelper.kirim_ke(
  user,
  pesan: "Pesanan ##{order.id} telah dikonfirmasi",
  tipe:  :sukses,
  link:  "/orders/#{order.id}"
)

Scaling WebSocket dengan Redis #

Ketika aplikasi berjalan di beberapa server (multiple Puma workers atau Heroku dynos), koneksi WebSocket tersebar di server yang berbeda. Redis pub/sub menjadi jembatan antar server:

# config/cable.yml
production:
  adapter: redis
  url: <%= ENV["REDIS_URL"] %>
  channel_prefix: <%= Rails.env %>

# Dengan Redis Sentinel untuk high availability
production:
  adapter: redis
  url: redis://localhost:26379/1
  sentinels:
    - host: sentinel1.example.com
      port: 26379
    - host: sentinel2.example.com
      port: 26379
  role: :master
Bagaimana Redis membantu scaling:

  Server 1 (Puma)          Server 2 (Puma)
  ┌─────────────────┐       ┌─────────────────┐
  │ User A terhubung│       │ User B terhubung│
  │ broadcast pesan │       │                 │
  └────────┬────────┘       └────────┬────────┘
           │                         │
           ▼                         ▼
     Redis Pub/Sub ──────────────────┘
           │
  Server 1 subscribe → terima broadcast → kirim ke User A
  Server 2 subscribe → terima broadcast → kirim ke User B

  Tanpa Redis: pesan dari Server 1 tidak sampai ke User B di Server 2
  Dengan Redis: semua server subscribe ke channel yang sama

Perbandingan Library WebSocket Ruby #

LibraryTergantungKelebihanKekuranganCocok untuk
ActionCableRailsTerintegrasi Rails, channel system, broadcasting mudahHanya untuk RailsAplikasi Rails
faye-websocketEventMachineMature, fleksibel, standaloneEventMachine bisa sulit di-debugRack app non-Rails
websocket-driverTidak adaSangat fleksibel, bisa di mana sajaLow-level, perlu kerja lebihLibrary lain yang butuh WS
IodineTidak adaWeb server sekaligus WS serverKurang populerHigh performance Ruby
AnyCableRails + extActionCable protocol, multi-bahasaSetup lebih kompleksScale Rails WebSocket

Ringkasan #

  • WebSocket untuk real-time, HTTP untuk request/response — pilih WebSocket hanya ketika membutuhkan komunikasi dua arah berkelanjutan; untuk data yang jarang update, Server-Sent Events (SSE) atau polling bisa lebih sederhana.
  • ActionCable untuk Rails — sudah terintegrasi, punya channel system, broadcasting dari model/job/controller, dan autentikasi berbasis session.
  • Autentikasi di Connection#connect — verifikasi identitas user sebelum koneksi diterima; jangan simpan state yang tidak di-autentikasi.
  • Broadcasting dari background job — untuk operasi yang butuh waktu, lakukan di Sidekiq job dan broadcast hasilnya ke klien; jangan block request HTTP untuk menunggu.
  • Redis adapter wajib di productionasync adapter hanya untuk development; production dengan multiple server memerlukan Redis sebagai pub/sub backend.
  • Heartbeat mencegah koneksi putus — proxy dan firewall sering menutup koneksi idle; ActionCable punya heartbeat bawaan, tapi pastikan juga sisi klien menangani reconnect.
  • Exponential backoff untuk reconnect — klien yang langsung reconnect setelah putus bisa membebani server; gunakan delay yang semakin lama untuk mencegah thundering herd.
  • Channel prefix di Redis — hindari konflik jika menggunakan Redis yang sama untuk beberapa environment atau aplikasi.
  • Implementasi manual hanya untuk pemahaman — protokol WebSocket punya banyak edge case; untuk produksi selalu gunakan library yang teruji.
  • stream_for model vs stream_from stringstream_for lebih aman karena nama stream dihasilkan dari model dan mencegah collision; gunakan stream_from hanya jika benar-benar perlu nama kustom.

← Sebelumnya: Socket   Berikutnya: Web Server →

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