Diving into Server-Sent Events (SSE) with Food Delivery Tracking Study Case 🏍️

Sammi Aldhi Yanto
12 min readJul 23, 2023

--

Photo by Sigmund on Unsplash

SSE adalah salah satu teknologi yang memungkinkan kita untuk mengirim data secara real-time dari server ke client melalui koneksi HTTP. Ini berarti kita bisa mengupdate informasi di client tanpa perlu melakukan refresh, sehingga pengalaman pengguna dalam berinteraksi akan lebih mulus dan responsif.

Jadi, daripada browser terus-menerus memeriksa ke server (polling) dengan harapan ada informasi baru, Server-Sent Events (SSE) memungkinkan server untuk mengirimkan data langsung ke browser begitu ada informasi baru yang tersedia.

Misalnya, SSE sangat cocok digunakan untuk pembaruan berita terkini, Subscribing to a Twitter feed, pembaruan harga saham, menerima skor olahraga secara live, atau bahkan untuk melacak lokasi driver pengiriman makanan (food delivery driver tracking) yang nanti akan kita jadikan studi kasus 😁

Photo by Rowan Freeman on Unsplash

Sekilas tentang Cara Kerja SSE

Koneksi melalui SSE biasanya dimulai dengan komunikasi yang diinisiasi oleh klien antara klien dan server. Klien membuat objek JavaScript baru bernama EventSource, yang mengirimkan URL endpoint ke server melalui permintaan HTTP biasa. Klien kemudian mengharapkan respon berupa event stream (aliran pesan) dari waktu ke waktu.

Server akan menjaga atau nge-keep HTTP connection sampai tidak ada lagi data yang akan dikirimkan, ataau bisa juga server memutuskan bahwa koneksi sudah terbuka cukup lama dan dianggap kadaluwarsa, ataau sampai klien secara eksplisit menutup permintaan awalnya.

Lebih Dalam tentang Server-Sent Events

Server-Sent Events adalah standar yang terdiri dari dua komponen:

  • EventSource Interface di browser, yang memungkinkan klien untuk berlangganan/subscribe pada aliran acara (event).
  • Format data “event stream” yang digunakan untuk mengirimkan pembaruan-pembaruan individual.

Interface EventSource menyediakan cara yang mudah untuk berlangganan pada aliran acara (stream) dengan menyembunyikan detail koneksi dan penanganan pesan di tingkat yang lebih rendah.

Berikut adalah contoh penggunaan Server-Sent Events:

const eventSource = new EventSource("/event-stream-endpoint");
eventSource.onmessage = event => {
const li = document.createElement("li");
const ul = document.getElementById("list");
li.textContent = `message: ${event.data}`;
ul.appendChild(li);
};

Dari cuplikan kode diatas, kita tidak perlu khawatir tentang bagaimana negosiasi koneksi berjalan, bagaimana membaca stream pesan, atau bagaimana menyampaikan pesan-pesan tersebut. Semua logika implementasi tersebut sudah diatasi secara otomatis.

Selain menyembunyikan logika yang lebih mendasar, EventSource juga memiliki beberapa fitur lain yang berguna, seperti:

  • Automatic reconnection: Jika klien terputus secara tak terduga, EventSource akan mencoba untuk menyambung kembali secara berkala.
  • Automatic stream resume: EventSource akan secara otomatis mengingat ID pesan terakhir yang diterima dan akan mengirimkan header “Last-Event-ID” saat mencoba menyambung kembali.

Autentikasi dan Keamanan

Seperti sumber daya web lainnya, aliran acara (event stream) juga bisa bersifat pribadi atau ditujukan untuk pengguna tertentu. Karena EventSource memulai koneksi dengan permintaan HTTP, browser akan otomatis mengirimkan cookie sesi bersama dengan permintaan tersebut, sehingga memungkinkan server untuk mengautentikasi dan mengotorisasi pengguna.

Namun, jika aplikasi menggunakan autentikasi berbasis token, terdapat beberapa tantangan ketika ingin mengirimkan token bersamaan dengan permintaan awal. Salah satu opsi adalah mengirimkan token dalam bentuk string kueri (query string), tetapi sebaiknya jangan mengirimkan data sensitif dalam bentuk string kueri karena alasan keamanan.

Apakah SSE bisa menjadi pengganti HTTP Streaming?

HTTP streaming dan SSE keduanya adalah metode pembaruan real-time. Meskipun keduanya mengikuti paradigma yang sama dengan mengirimkan respon secara bertahap melalui koneksi yang berlangsung lama, perbedaan utama di sini adalah bahwa HTTP streaming tidak mengikuti standar terbuka dan tidak memiliki library bawaan. Oleh karena itu, negosiasi koneksi, pembacaan stream data, dan dekode data menjadi tanggung jawab kita sebagai pengembang.

Secara keseluruhan, SSE menawarkan pendekatan yang lebih mudah dan efisien dalam mengirimkan pembaruan dari server ke klien (One-way message transmission ‘server to client’) berdasarkan protokol HTTP, terutama ketika sebagian besar pembaruan hanya perlu dikirimkan dari server ke klien saja.

SSE memberikan alternatif yang menarik dan dapat menjadi pilihan yang tepat tergantung pada kebutuhan dan skenario aplikasi yang digunakan.

Studi Kasus: Food Delivery Driver Tracking

Photo by Lucian Alexe on Unsplash

Pada studi kasus ini, kita akan membuat simple web app yang memungkinkan kita untuk melacak lokasi driver pengiriman makanan secara real-time. Aplikasi ini akan terdiri dari dua bagian, yaitu:

  • Server: Aplikasi server yang akan mengirimkan pembaruan lokasi driver ke klien (browser) melalui SSE. untuk lokasi driver akan digenerate secara random. Untuk server side kita akan gunakan bahasa pemrograman Golang.
  • Client: Aplikasi client yang akan menerima pembaruan lokasi driver dan menampilkan lokasi driver tersebut di peta (map). Untuk client side kita akan gunakan bahasa pemrograman JavaScript dan library LeafletJS untuk menampilkan peta (map).

Project Structure

main.go

type Location struct {
FoodOrderID int `json:"food_order_id"`
CurrentLocation *Point `json:"current_location"`
TargetLocation *Point `json:"target_location"`
Speed float64 `json:"speed"`
HasArrived bool `json:"has_arrived"`
ETA int `json:"eta"` // Estimated time of arrival in seconds
}

type Point struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
}

Structs di atas merupakan representasi untuk mengelola informasi lokasi pada aplikasi delivery track. Location digunakan untuk menyimpan data lokasi terkini dari pesanan makanan, termasuk ID pesanan, koordinat saat ini, tujuan, kecepatan, status kedatangan, dan estimasi waktu tiba. Point digunakan untuk menyimpan koordinat dengan nilai latitude dan longitude.

const (
kecepatanMeterPerDetik = 100
latMonas = -6.1754
lngMonas = 106.8272
latLngFactor = 111111
)

Kode di atas merupakan penggunaan konstanta (const) dalam bahasa pemrograman Go (Golang). Konstanta digunakan untuk mendefinisikan nilai tetap yang tidak dapat diubah selama program berjalan. Berikut adalah penjelasan dari masing-masing konstanta:

kecepatanMeterPerDetik = 100: Konstanta ini menetapkan nilai kecepatan dalam meter per detik. Nilainya adalah 100, yang berarti kecepatan yang digunakan dalam aplikasi adalah 100 meter per detik.

latMonas = -6.1754: Konstanta ini menetapkan nilai latitude Monumen Nasional (Monas) di Jakarta, Indonesia. Nilainya adalah -6.1754, yang menggambarkan koordinat lintang dari lokasi Monas.

lngMonas = 106.8272: Konstanta ini menetapkan nilai longitude Monumen Nasional (Monas) di Jakarta, Indonesia. Nilainya adalah 106.8272, yang menggambarkan koordinat bujur dari lokasi Monas.

latLngFactor = 111111: Konstanta ini digunakan untuk menghitung jarak antara dua titik koordinat dalam satuan meter menggunakan rumus Haversine. Nilainya adalah 111111, yang merupakan perkiraan konversi derajat lintang atau bujur ke meter.

Nantinya untuk driver akan start dari koordinat Monas, Jakarta ini, tujuan pengirimannya akan diambil dari lokasi customer yang memesan makanan.

func distanceBetweenPoints(p1, p2 *Point) float64 {
// Calculate the distance between two points using Haversine formula
lat1Rad := p1.Lat * (math.Pi / 180)
lng1Rad := p1.Lng * (math.Pi / 180)
lat2Rad := p2.Lat * (math.Pi / 180)
lng2Rad := p2.Lng * (math.Pi / 180)

latDiff := lat2Rad - lat1Rad
lngDiff := lng2Rad - lng1Rad

a := math.Pow(math.Sin(latDiff/2), 2) + math.Cos(lat1Rad)*math.Cos(lat2Rad)*math.Pow(math.Sin(lngDiff/2), 2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))

distance := latLngFactor * c
return distance
}

Fungsi distanceBetweenPoints pada koding di atas digunakan untuk menghitung jarak antara dua titik koordinat berdasarkan formula Haversine. Fungsi ini mengambil dua parameter p1 dan p2, yang merupakan pointer ke struct Point yang menyimpan nilai latitude (Lat) dan longitude (Lng) dari masing-masing titik.

Berikut adalah penjelasan langkah-langkah perhitungan dalam fungsi distanceBetweenPoints:

  1. Mengonversi nilai latitude dan longitude dari derajat ke radian, karena formula Haversine menggunakan satuan radian dalam perhitungannya.
lat1Rad := p1.Lat * (math.Pi / 180)
lng1Rad := p1.Lng * (math.Pi / 180)
lat2Rad := p2.Lat * (math.Pi / 180)
lng2Rad := p2.Lng * (math.Pi / 180)

2. Menghitung selisih antara latitude dan longitude dari kedua titik dalam bentuk radian.

latDiff := lat2Rad - lat1Rad
lngDiff := lng2Rad - lng1Rad

3. Menggunakan formula Haversine untuk menghitung jarak antara dua titik koordinat. Untuk informasi lebih lanjut tentang formula Haversine, Anda dapat membaca https://en.wikipedia.org/wiki/Haversine_formula

a := math.Pow(math.Sin(latDiff/2), 2) + math.Cos(lat1Rad)*math.Cos(lat2Rad)*math.Pow(math.Sin(lngDiff/2), 2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))

4. Mengalikan hasil perhitungan dengan faktor latLngFactor untuk mengkonversi hasil jarak dari radian menjadi satuan meter dan mengembalikan hasilnya.

distance := latLngFactor * c
return distance
func generateLocation(foodOrderID int, currentLocation *Point, targetLocation *Point, speed float64, timeElapsed int) Location {
distance := distanceBetweenPoints(currentLocation, targetLocation)

// Calculate the time required to reach the targetLocation
timeRequired := distance / speed

// If the time elapsed is greater than or equal to time required, set the current location to the target location
if float64(timeElapsed) >= timeRequired {
return Location{
FoodOrderID: foodOrderID,
CurrentLocation: targetLocation,
TargetLocation: targetLocation,
Speed: speed,
HasArrived: true, // Set has_arrived to true
ETA: 0, // ETA is 0 as the target has been reached
}
}

// Calculate the ratio of timeElapsed to timeRequired
ratio := float64(timeElapsed) / timeRequired

// Calculate the lat and lng difference between target and current location
latDiff := targetLocation.Lat - currentLocation.Lat
lngDiff := targetLocation.Lng - currentLocation.Lng

// Calculate the lat and lng for the current location
lat := currentLocation.Lat + (latDiff * ratio)
lng := currentLocation.Lng + (lngDiff * ratio)

// Calculate the remaining time to reach the targetLocation in seconds
remainingTime := int((timeRequired - float64(timeElapsed)) * float64(time.Second/time.Millisecond))

return Location{
FoodOrderID: foodOrderID,
CurrentLocation: &Point{
Lat: lat,
Lng: lng,
},
TargetLocation: targetLocation,
Speed: speed,
HasArrived: false, // Set has_arrived to false
ETA: remainingTime,
}
}

Fungsi generateLocation pada kode di atas digunakan untuk menghasilkan informasi lokasi berdasarkan parameter yang diberikan, khususnya dalam konteks pelacakan pengiriman pesanan makanan (food delivery tracking). Berikut adalah penjelasan ringkas dari setiap langkah dalam fungsi tersebut:

Menghitung jarak antara lokasi saat ini (currentLocation) dan lokasi tujuan (targetLocation) dengan memanggil fungsi distanceBetweenPoints.

Menghitung waktu yang diperlukan untuk mencapai lokasi tujuan berdasarkan kecepatan (speed). Ini dilakukan dengan membagi jarak dengan kecepatan.

Memeriksa apakah waktu yang telah berlalu (timeElapsed) sudah cukup untuk mencapai lokasi tujuan. Jika iya, maka lokasi saat ini dianggap sama dengan lokasi tujuan, dan status kedatangan (HasArrived) diatur menjadi true, dan estimasi waktu tiba (ETA) diatur menjadi 0.
Jika waktu yang telah berlalu belum mencukupi untuk mencapai lokasi tujuan, maka perhitungan berlanjut:

a. Menghitung rasio waktu yang telah berlalu (timeElapsed) terhadap waktu yang diperlukan (timeRequired). Ini akan menentukan seberapa jauh perjalanan sudah dilakukan.

b. Menghitung selisih latitude (latDiff) dan longitude (lngDiff) antara lokasi tujuan dan lokasi saat ini.

c. Menghitung latitude (lat) dan longitude (lng) untuk lokasi saat ini berdasarkan rasio perjalanan yang telah dilakukan.

d. Menghitung sisa waktu yang dibutuhkan untuk mencapai lokasi tujuan dalam satuan detik (remainingTime).

Mengembalikan objek Location yang berisi informasi lokasi yang dihasilkan dari perhitungan di atas.

func sendLocationUpdates(w http.ResponseWriter, r *http.Request) {
// Dapatkan food_order_id dari parameter permintaan
foodOrderID := chi.URLParam(r, "food_order_id")
if foodOrderID == "" {
http.Error(w, "Missing food_order_id parameter", http.StatusBadRequest)
return
}

// Konversi food_order_id ke tipe data integer
// Pastikan bahwa food_order_id adalah angka valid
foodOrderIDInt, err := strconv.Atoi(foodOrderID)
if err != nil {
http.Error(w, "Invalid food_order_id parameter", http.StatusBadRequest)
return
}

// Dapatkan lokasi target dari parameter query
latStr := r.URL.Query().Get("lat")
lngStr := r.URL.Query().Get("lng")

lat, err := strconv.ParseFloat(latStr, 64)
if err != nil {
http.Error(w, "Invalid latitude parameter", http.StatusBadRequest)
return
}

lng, err := strconv.ParseFloat(lngStr, 64)
if err != nil {
http.Error(w, "Invalid longitude parameter", http.StatusBadRequest)
return
}

targetLocation := &Point{
Lat: lat,
Lng: lng,
}

// Set header untuk SSE
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")

// Channel untuk mengirim data lokasi ke klien melalui SSE
locationChan := make(chan Location)

// Fungsi untuk mengirim data lokasi ke klien melalui channel
go func() {
defer close(locationChan) // Close the channel when the function exits
timeElapsed := 0
for {
// Generate lokasi berdasarkan food_order_id, currentLocation, targetLocation, speed, dan waktuElapsed
currentLocation := &Point{Lat: latMonas, Lng: lngMonas}
newLocation := generateLocation(foodOrderIDInt, currentLocation, targetLocation, kecepatanMeterPerDetik, timeElapsed)

// Kirim lokasi baru ke channel
locationChan <- newLocation

// Cek apakah sudah sampai di lokasi target
if newLocation.CurrentLocation.Lat == targetLocation.Lat && newLocation.CurrentLocation.Lng == targetLocation.Lng {
// Set has_arrived to true and return
newLocation.HasArrived = true
locationChan <- newLocation
return
}

// Tambahkan waktuElapsed sebelum mengirim lokasi selanjutnya
timeElapsed += 1

// Tunggu selama 250 milidetik sebelum mengirim lokasi selanjutnya
time.Sleep(250 * time.Millisecond)
}
}()

// Loop tak terbatas untuk mengirim lokasi ke klien melalui SSE
for newLocation := range locationChan {
data, err := json.Marshal(newLocation)
if err != nil {
// Handle error
continue
}

_, err = fmt.Fprintf(w, "data: %s\n\n", data)

if err != nil {
// Client connection closed, exit the loop
return
}

// Flush data to the client
if f, ok := w.(http.Flusher); ok {
f.Flush()
}

// If has_arrived is true, close the channel and return
if newLocation.HasArrived {
return
}
}
}

Fungsi sendLocationUpdates pada kode di atas digunakan untuk mengirimkan pembaruan lokasi secara real-time kepada klien menggunakan Server-Sent Events (SSE). Berikut adalah penjelasan singkat dari setiap langkah dalam fungsi tersebut:

Mendapatkan food_order_id dari parameter permintaan dan memastikan bahwa parameter tersebut tidak kosong. Kemudian, mengonversi food_order_id ke tipe data integer.

Mendapatkan lokasi tujuan (targetLocation) dari parameter query berupa latitude (lat) dan longitude (lng), dan memastikan bahwa kedua parameter tersebut valid.

Mengatur header pada respons HTTP agar sesuai dengan SSE, seperti “Content-Type: text/event-stream”, “Cache-Control: no-cache”, dan “Connection: keep-alive”.

Membuat channel (locationChan) untuk mengirimkan data lokasi ke klien melalui SSE.

Melakukan perulangan tak terbatas menggunakan goroutine untuk mengirimkan pembaruan lokasi ke klien. Setiap pembaruan lokasi dihasilkan menggunakan fungsi generateLocation, dan selanjutnya dikirimkan melalui channel locationChan.

Dalam perulangan utama, data lokasi dikonversi menjadi format JSON dan dikirimkan ke klien menggunakan fmt.Fprintf. Selain itu, menggunakan http.Flusher untuk mengirimkan data segera ke klien.

Perulangan terus berlanjut hingga lokasi mencapai lokasi tujuan (targetLocation) dan variabel HasArrived diatur menjadi true.

Setelah lokasi mencapai tujuan dan HasArrived diatur menjadi true, channel locationChan akan ditutup dan fungsi keluar.

func main() {
r := chi.NewRouter()

fs := http.FileServer(http.Dir("./static"))
r.Handle("/static/*", http.StripPrefix("/static/", fs))

r.Get("/location/{food_order_id}", sendLocationUpdates)

r.Get("/", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "index.html")
})

log.Println("Server is running on port 8080")
_ = http.ListenAndServe(":8080", r)
}

Kode di atas digunakan untuk mengatur initialisasi server dan routing.

Sekarang kita akan jump to frontend. file index.html berisi kode HTML dan JavaScript untuk menampilkan peta dan pembaruan lokasi secara real-time.

<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Live Maps with Leaflet and SSE</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<style>
#map {
height: 400px;
width: 80%;
margin: 0 auto;
}

#eta {
text-align: center;
font-size: 1.5rem;
margin-top: 1rem;
}
</style>
</head>

<body>
<div id="map"></div>
<div id="eta"></div> <!-- Add a div to display the ETA -->
<script type="text/javascript" async src="https://tenor.com/embed.js"></script>
<script>
// Inisialisasi peta dengan koordinat pusat indonesia
const map = L.map('map').setView([-6.1753924, 106.8271528], 7);

// Tambahkan layer peta dari OpenStreetMap
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);

// Tambahkan custom marker icon untuk motor
const motorIcon = L.icon({
iconUrl: '/static/motorcycle.png', // Replace with the actual URL of your custom icon
iconSize: [36, 36], // Replace with the size of your custom icon
iconAnchor: [16, 32], // Replace with the anchor point of your custom icon (usually half of iconSize)
});

// Tambahkan marker di koordinat acak awal dengan custom icon motor
const marker = L.marker([0, 0], {icon: motorIcon}).addTo(map);

// Fungsi untuk mengatur marker dengan koordinat baru
function updateMarker(coordinates) {
marker.setLatLng(coordinates);
}

// Dapatkan lokasi saat ini menggunakan Geolocation API
function getCurrentLocation() {
if ("geolocation" in navigator) {
navigator.geolocation.getCurrentPosition(
(position) => {
const lat = position.coords.latitude;
const lng = position.coords.longitude;
const targetLocation = [lat, lng];

// Buat koneksi SSE ke backend dengan lokasi target yang didapatkan
const randomOrderId = Math.floor(Math.random() * 1000); // Generate random food_order_id
const eventSource = new EventSource('/location/' + randomOrderId + '?lat=' + lat + '&lng=' + lng); // Replace "123" with the desired food_order_id

// Tangani data yang diterima dari SSE
eventSource.onmessage = function (event) {
const data = JSON.parse(event.data);
console.log('Received location:', data);
updateMarker([data.current_location.lat, data.current_location.lng]);

// Cek apakah makanan telah sampai ke lokasi target
if (data.has_arrived) {
// Tutup koneksi SSE
eventSource.close();
// Tampilkan alert "makanan telah sampai"
alert('makanan telah sampai ;)');
}

// Tampilkan ETA jika ada
if (data.eta > 0) {
const etaElement = document.getElementById('eta');
const minutes = Math.floor(data.eta / 60); // convert seconds to minutes
const seconds = data.eta % 60; // get the remaining seconds
etaElement.innerText = `Sampai dalam waktu ${minutes} menit dan ${seconds} detik.`;
}
};

// Tangani error jika ada
eventSource.onerror = function (error) {
console.error('SSE Error:', error);
eventSource.close(); // Tutup koneksi SSE jika terjadi error
};

// Jangan lupa tutup koneksi SSE ketika halaman ditutup
window.addEventListener('beforeunload', function () {
eventSource.close();
});
},
(error) => {
console.error('Error getting current location:', error);
}
);
} else {
console.error('Geolocation is not supported by this browser.');
}
}

getCurrentLocation();
</script>
</body>

</html>

Yang menarik untuk di highlight dari kode diatas adalah Fungsi updateMarker(coordinates) digunakan untuk mengatur posisi marker di peta berdasarkan koordinat baru.

Fungsi getCurrentLocation() digunakan untuk mendapatkan lokasi saat ini melalui Geolocation API. Jika Geolocation didukung oleh browser, maka fungsi ini akan menginisialisasi koneksi SSE ke backend server dengan lokasi target yang didapatkan melalui Geolocation API. Selain itu, fungsi ini juga menangani data yang diterima dari SSE, mengupdate marker di peta dengan lokasi baru, menampilkan ETA (Estimated Time of Arrival), dan menutup koneksi SSE jika makanan telah sampai ke lokasi target.

Fungsi getCurrentLocation() juga menangani error yang mungkin terjadi saat mendapatkan lokasi menggunakan Geolocation API dan menutup koneksi SSE jika terjadi error. Selain itu, fungsi ini juga menutup koneksi SSE ketika halaman ditutup.

Fungsi getCurrentLocation() akan dijalankan secara otomatis ketika halaman selesai dimuat, sehingga pembaruan lokasi akan dimulai segera setelah halaman dibuka.

Yeii Demo Time :D

Terima kasih yaa, sudah membaca sampai akhir, semoga bermanfaat and happy coding :D

--

--