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 terbukaHTTP 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 sepertiwebsocket-driverataufaye-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 --> ASetup 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 #
| Library | Tergantung | Kelebihan | Kekurangan | Cocok untuk |
|---|---|---|---|---|
| ActionCable | Rails | Terintegrasi Rails, channel system, broadcasting mudah | Hanya untuk Rails | Aplikasi Rails |
| faye-websocket | EventMachine | Mature, fleksibel, standalone | EventMachine bisa sulit di-debug | Rack app non-Rails |
| websocket-driver | Tidak ada | Sangat fleksibel, bisa di mana saja | Low-level, perlu kerja lebih | Library lain yang butuh WS |
| Iodine | Tidak ada | Web server sekaligus WS server | Kurang populer | High performance Ruby |
| AnyCable | Rails + ext | ActionCable protocol, multi-bahasa | Setup lebih kompleks | Scale 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 production —
asyncadapter 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 modelvsstream_from string—stream_forlebih aman karena nama stream dihasilkan dari model dan mencegah collision; gunakanstream_fromhanya jika benar-benar perlu nama kustom.