← Melih Sahtiyan
14 dk okumaGathera

Kendi Discord'umu yazmak: ücretsiz tek bir ARM sunucuda ses, ekran paylaşımı ve gerçek zamanlı sohbet

Discord benzeri, gerçek zamanlı bir iletişim platformu. 2025'te yarım kalan projeyi bir yıl sonra yeniden açtığımda mesajlar ekrana bile gelmiyordu; bir hafta içinde ses, 1080p ekran paylaşımı, dönen oturumlar ve tek bir erişim kuralıyla ücretsiz bir ARM sunucuda yayına aldım.

1 / 6

Sunucu sayfası: board'lar, ses odaları, son etkinlik ve üyeler (koyu tema).

Kısaca

  • Ne yaptım: Discord benzeri, gerçek zamanlı bir iletişim platformu geliştirdim. Sunucular, metin ve ses kanalları, birebir ve grup DM'leri, DM aramaları, dosya yükleme, GIF'ler, 1080p ekran paylaşımı ve soundboard hepsi içinde. Backend Node.js + MongoDB, istemci React + Electron, ses ise kendi barındırdığım bir LiveKit sunucusundan geçiyor.
  • Başlangıç noktası: 2025 yazında başlayıp yarım bıraktığım bir projeydi. Ekim 2026'da yeniden açtığımda kanal sayfaları sonsuz bir istek döngüsüne giriyor, mesajlar ekrana hiç gelmiyordu.
  • Bir hafta içinde: Ses ve ekran paylaşımı, yenilenen oturumlar, tek bir erişim kuralı, okunmamış mesaj takibi ve geçmişe kaydırma eklendi; uygulama Docker Compose ile ücretsiz bir ARM sunucuda yayına girdi.
  • Kısıt: Her şey, başka servislerin de çalıştığı tek bir ücretsiz Oracle Always Free ARM makinesinde (2 çekirdek, 11 GiB) çalışmak zorunda. Gathera'nın bütün container'larını tek bir çekirdeğe sabitledim ve her birine bellek sınırı koydum.
  • En ilginç problem: Windows'ta ekran paylaşımına yalnızca paylaşılan uygulamanın sesini eklemek, görüşmedeki sesleri karşı tarafa geri göndermeden.
  • Dürüst durum: Repoda kalıcı bir otomatik test paketi yok. Her değişiklik çalışan sisteme karşı betiklerle sınandı, ama o betikler repoya girmedi.

Bir bakışta

TürYan proje · yalnızca davet koduyla kayıt
RolTek kişi: ürün kararları, mimari, backend, masaüstü istemci, dağıtım
Süreİlk sürüm Temmuz–Ağustos 2025; Ekim 2026'da yeniden ele alındı, devam ediyor
BackendNode.js · TypeScript · Express 5 · MongoDB (Mongoose, replica set) · Socket.IO · InversifyJS · sharp
İstemciReact 19 · Vite · Tailwind · Redux Toolkit · Electron 37 · livekit-client
AltyapıDocker Compose · Caddy (otomatik HTTPS) · self-hosted LiveKit SFU · Oracle Always Free ARM
Büyüklük~13,6 bin satır backend TypeScript, ~23 bin satır istemci (React + Electron); iki repo, 60 commit

Problem

Hedef, insanların gerçekten her gün açıp kullanabileceği, kendi sunucumda çalışan bir iletişim platformuydu. Bu "mesaj gönderip alan bir demo"dan çok daha fazlası demek: mesajlar sayfa yenilenmeden gelmeli, ses kesilmemeli, ekran paylaşımı bir oyunu izlenebilir kalitede göstermeli, oturum her yeniden yüklemede düşmemeli ve kayıt yalnızca davetle olmalı.

Discord benzeri bir uygulamayı baştan yazmak, gerçek zamanlı sistemlerin zor kısımlarının neredeyse hepsini tek bir projede topluyor: sıralı mesaj saklama, push olayları, WebRTC, kimlik doğrulama, dosya saklama ve dağıtım.

Kısıtlar

  • Bütçe sıfır. İlk planım Hetzner'de küçük bir sunucuydu (~10 kullanıcı için 2 vCPU / 4 GB). Sonra elimde zaten var olan ücretsiz Oracle ARM makinesine geçtim. İkinci bir ücretsiz makine açmak mümkün değildi, hesabın limiti o tek makineyle dolmuştu.
  • Sunucu paylaşımlı. Aynı makinede başka servisler de çalışıyor; Gathera onları yavaşlatmamalıydı.
  • Asıl istemci bir Windows masaüstü uygulaması. Electron ile paketleniyor; aynı kodun tarayıcıda da çalışması gerekiyor.
  • Davetle kayıt. Herkese açık kayıt yok; hesap açmak için bir davet kodu gerekiyor.

Mimari

  Electron app / browser  (React 19 + Redux)
     │ REST            │ Socket.IO          │ WebRTC (voice, screen share)
     ▼                 ▼                    ▼
┌──────────────── Caddy · HTTPS (Let's Encrypt) ─────────────────┐
│  /api/*  ─────────────► backend      /livekit/rtc* ──► LiveKit │
│  any other path ──► 404                                        │
└────────────────────────────────────────────────────────────────┘
 
 backend · Express 5 + InversifyJS
   presentation → application → domain ← persistence
   access.ts            one rule for who may read a channel
   Socket.IO            a room per user: user:<id>
   uploads              random names, signed links that expire in 24 h
        │ internal network only                │ 10-minute join token
        ▼                                      ▼
 MongoDB (single-node replica set)        LiveKit SFU
   users · servers · channels               voice · screen share
   chunks → messages · readstates           data channel (soundboard)

Backend; sunum, uygulama, alan ve kalıcılık katmanlarına ayrılmış bir Clean Architecture yapısında. Bağımlılıklar InversifyJS ile enjekte ediliyor. MongoDB transaction kullanıldığı için tek düğümlü bir replica set olarak çalışıyor ve dışarıya hiç açık değil: yalnızca backend'in bulunduğu iç ağdan erişilebiliyor.

Önemli kararlar

1. Mesajları "ağırlıklı chunk"lar halinde saklamak

Her kanalın mesajları birbirine bağlı chunk'lardan oluşan bir listede duruyor; yeni mesaj her zaman son chunk'a ekleniyor. Bir chunk, ağırlığı 100'e ulaşınca doluyor ve ağırlık mesajın türüne göre değişiyor:

TürAğırlık
Metin1
Dosya5
Ses kaydı · GIF10
Görsel20
Video33

Böylece bir sayfanın yükleme maliyeti öngörülebilir kalıyor: bir chunk 100 metin mesajı da taşıyabilir, yalnızca üç video da. Medyası ağır bir konuşmanın sayfaları kendiliğinden kısalıyor. Bir mesaj silindiğinde ağırlık düşürülmüyor; ağırlığın tek işi bir sonraki chunk'ın ne zaman başlayacağına karar vermek.

Fikir 2025'ten, ama tasarımın doğru olması kodun doğru olduğu anlamına gelmiyordu. Chunk'ın dolduğu anda dört ayrı hata vardı: yeni chunk kanala iki kez ekleniyor, eski chunk'ın nextChunkId alanı null ile eziliyor, yeni chunk'ı başlatan mesaj eski chunkId'yi taşıdığı için ekrandan kayboluyor ve kanalın lastChunkId'si hiçbir zaman ilk chunk'ın ötesine geçmiyordu. Hepsi ancak 100. metin mesajından sonra ortaya çıkan, küçük bir demonun hiç karşılaşmayacağı hatalardı.

Geçmişe kaydırmayı kurarken bir karar daha verdim: eski verilerdeki previousChunkId bağlantılarına güvenmedim. Sayfalama, bağlantıları izlemek yerine chunk'ları kanala ve oluşturulma sırasına göre buluyor ve bir sayfa 40 mesajı geçene kadar bütün chunk'ları topluyor. Bunu 6 chunk'a yayılmış 505 mesajla, sayfa açıkken gelen 200 mesajlık bir dalga da dahil olmak üzere sınadım. Henüz yüklenmemiş eski bir sabitlenmiş mesaja ya da alıntıya atlamak da aynı yoldan geçiyor: mesaj bulunana kadar sayfalar yükleniyor, sonra mesaj vurgulanıyor.

2. Yetki kontrolü endpoint endpoint değil, tek bir kural

Yeniden ele aldığımda en ciddi açık buydu: GET /messages/:chunkId ve POST /messages, kullanıcının o kanalı okuyup okuyamayacağına hiç bakmıyordu. Bir chunk kimliğini bilen herkes mesajları okuyabilirdi.

Bunu her endpoint'e ayrı bir if ekleyerek değil, tek bir kural yazarak çözdüm (access.ts): herkese açık sunucu kanallarını sunucunun üyeleri, özel kanalları, DM'leri ve grupları ise yalnızca kendi üyeleri okuyabilir. Kanal ve sunucu okumaları, davetler, mesaj okuma ve gönderme, dosya yükleme, sabitlemeler ve canlı olaylar hep bu kuralı kullanıyor. Reddedilen bir yüklemenin dosyası siliniyor. Bu sırada bir sızıntı daha kapandı: özel kanalların canlı olayları artık bütün sunucuya değil, yalnızca kanalın üyelerine gidiyor.

3. Ses için kendi barındırdığım bir SFU

Mesh WebRTC'de her katılımcı yayınını diğer herkese ayrı ayrı gönderir. Birkaç kişiye 1080p bir ekran paylaşımını ev bağlantısının upload hızıyla göndermek bu yüzden mümkün değil. Bir SFU ise yayını bir kez alıp izleyenlere dağıtır. LiveKit'i bulut hizmeti yerine aynı Compose yığınında kendim çalıştırıyorum: ek ücret yok.

Backend yalnızca sunucunun ya da DM'in üyelerine 10 dakikalık bir katılım token'ı veriyor. Token mikrofon, ekran paylaşımı, paylaşım sesi ve soundboard için veri kanalı izni taşıyor; başka hiçbir şey yapamıyor.

4. Ekran paylaşımı: izlemek isteğe bağlı

Paylaşım LiveKit'in 1080p 30 fps ayarıyla (5 Mbit/s'ye kadar) ve küçük görünümler için 960 piksel genişliğinde, 1,25 Mbit/s'lik ikinci bir katmanla yayınlanıyor.

Yavaş bilgisayarları düşünerek izlemeyi isteğe bağlı yaptım. Sesler otomatik değil, elle abone olunarak alınıyor (autoSubscribe: false). Bir paylaşım ekranda yalnızca "X ekranını paylaşıyor · İzle" olarak görünüyor ve İzle'ye basılana kadar hiçbir şey indirilmiyor ya da çözülmüyor; ✕ ile izlemeyi bırakınca abonelik de kalkıyor. LiveKit'in kayıtlarında, paylaşıma yalnızca İzle'den sonra abone olunduğunu ve izlemeyi bırakınca aboneliğin kalktığını kontrol ettim.

5. Ekran paylaşımına doğru sesi eklemek

Bilgisayarın sesini olduğu gibi paylaşmanın bir bedeli var: görüşmedeki seslerin kendisi de yakalanıp karşı tarafa geri gidiyor ve yankı oluşuyor.

Çözüm, Windows'un WASAPI process loopback özelliği oldu (Electron'un ana sürecinde, loopback-capture paketiyle):

  • Bir pencere paylaşılıyorsa o pencerenin sahibi olan süreç bulunuyor ve yalnızca o uygulamanın sesi yakalanıyor.
  • Bütün ekran paylaşılıyorsa her şey yakalanıyor, Gathera'nın kendi süreç ağacı hariç. Görüşmenin yankısı böylece hiç oluşmuyor.

48 kHz, 16 bit stereo PCM bir AudioWorklet ile sayfaya aktarılıyor ve paylaşımın stereo ses kanalı olarak yayınlanıyor. Bir test uygulamasıyla pencere yakalamanın yalnızca o uygulamanın sesini aldığını, süreç ağacını dışarıda bırakmanın sesini gerçekten düşürdüğünü doğruladım. Bir DM aramasında LiveKit, paylaşım sesini ~70 kbit/s stereo Opus olarak, kayıpsız ve yalnızca izleyen taraf İzle'ye bastığında abone olunmuş halde gösterdi. Bu özellik yalnızca Windows 10 2004 ve sonrasında çalışıyor.

6. Oturumlar: kısa ömürlü access token, her kullanımda dönen refresh token

Eski sürümde token yenileme ve çıkış metotları yalnızca "Method not implemented" fırlatıyordu; token süresi dolunca kullanıcı yeniden giriş yapmak zorundaydı. Şimdiki düzen şöyle:

  • Access token 15 dakika yaşıyor. Refresh token her kullanımda dönüyor ve eski bir refresh token'ın tekrar kullanılması o oturumu tamamen sonlandırıyor; çalınmış bir token bu sayede tek seferlik kalıyor. Refresh token'lar veritabanında hash'lenmiş olarak duruyor, "Beni hatırla" ile 30, yoksa 7 gün geçerli ve bir TTL index ile kendiliğinden siliniyor.
  • İstemci 401 aldığında tek bir ortak yenileme yapıyor ve isteği tekrarlıyor; süre dolmadan bir dakika önce de kendiliğinden yeniliyor. Sekmeler bir Web Lock ile sıraya giriyor. Bu olmasa iki sekme aynı refresh token'ı aynı anda kullanır, ikinci kullanım "tekrar kullanım" sayılır ve kullanıcı oturumdan atılırdı.
  • Socket'ler de bu düzene dahil. Token'ı dolan bir socket, istemci yenilenmiş token'ı göndermezse sunucu tarafından kapatılıyor. Reddedilen bir bağlantıdan sonra istemci sessizce kopmak yerine token'ı yenileyip yeniden bağlanıyor.

Bunlara ek olarak backend, 32 karakterden kısa bir JWT_SECRET ile artık hiç açılmıyor (kodda bir "default_jwt_secret" yedeği vardı), doğrulama hataları şifreleri yanıtta geri göndermiyor ya da loglamıyor, giriş ve kayıt denemeleri hız sınırına tabi.

7. Ücretsiz tek bir ARM sunucuya sığmak

Makinede 2 çekirdek ve 11 GiB bellek var ve üzerinde başka servisler de çalışıyor. Gathera'yı oraya şöyle yerleştirdim:

KararNeden
Gathera'nın bütün container'ları 1 numaralı çekirdeğe sabitlendi0 numaralı çekirdek, Gathera ne kadar yüklenirse yüklensin makinedeki diğer servislere kalıyor
Her container'a bellek sınırı (Caddy 256 MB, backend 1 GB ve 512 MB Node heap, Mongo 1 GB, LiveKit 512 MB)Bir sızıntı ya da yük artışı makinenin geri kalanını değil, yalnızca o container'ı etkilesin
MongoDB'nin önbelleği 0,5 GB ile sınırlandıMongo'nun varsayılan önbelleği belleğin yaklaşık yarısını hedefliyor; bu makinede o kadar boş bellek yok
4 GB swap, vm.swappiness=10Ani bir bellek artışında süreçler öldürülmek yerine yavaşlasın
Container logları en fazla 5 × 10 MBLogların diski sessizce doldurmaması için

Yığın 15 Ekim 2026'da yayına girdi ve Let's Encrypt sertifikası alındı. Bellek sınırlarının şimdilik tahmin olduğunu da not ettim; dağıtımdan sonra docker stats ile kontrol edilecekler.

8. Davetle kayıt olunan bir platform için güvenlik

Sunucu internete açık, ama hesap yalnızca davet koduyla açılabiliyor. Bu dengeyi şu kararlarla kurdum:

  • Kayıt için davet kodu şart. Kod, e-posta ve kullanıcı adından önce kontrol ediliyor; yani kod olmadan hangi hesapların var olduğu yoklanamıyor.
  • Caddy yalnızca iki yolu geçiriyor: /api backend'e, /livekit/rtc LiveKit'e. Geri kalan her şey, LiveKit'in yönetim API'si de dahil, 404 alıyor.
  • Sırlar ortam değişkenlerinde değil, Docker secret dosyalarında duruyor; docker inspect ya da docker compose config çıktısında görünmüyorlar.
  • Container'lar salt okunur dosya sistemiyle, ek ayrıcalık olmadan (no-new-privileges) ve Linux yetenekleri düşürülmüş halde çalışıyor. Mongo, Caddy'nin ve LiveKit'in erişemediği bir iç ağda.
  • Kullanıcı başına sınırlar: 10 saniyede 10 mesaj, günde 1 GB yükleme, dakikada 60 arama ve benzerleri. 64 megapikseli aşan görseller daha çözülmeden reddediliyor.
  • Başkalarının e-postaları hiçbir yanıtta yok: arama, profiller, arkadaş listeleri, üye listeleri ve mesaj yazarları artık yalnızca herkese açık alanları döndürüyor.
  • Kullanıcı adı araması regex karakterlerini kaçırıyor. (a+)+$ gibi bir arama eskiden 500 hatası ya da yavaş bir sorgu üretiyordu, şimdi birkaç milisaniyede boş dönüyor.
  • Soundboard çalmaları sunucuda kayıtlı sesten üretiliyor. İstemcinin gönderdiği URL, ad ve ses seviyesi yok sayılıyor. Sahte bir çalma isteği, karşı tarafa gerçek "Airhorn" olarak ve hiçbir URL taşımadan ulaştı.
  • Tarayıcıdan API'ye yalnızca masaüstü uygulaması ve izin verilen siteler erişebiliyor. Electron 37'nin REST isteklerinde hiç Origin göndermediğini, WebSocket'te ise file:// gönderdiğini ölçerek bu kuralı ona göre kurdum. Origin göndermeyen betikler yine de uygulama gibi davranabildiği için asıl kapının giriş ve kullanıcı başına sınırlar olduğunu da açıkça yazdım.

Ancak çalıştırınca ortaya çıkanlar

Bunların hiçbiri mimari diyagramında görünmüyor, ama her biri kullanıcının gözünde "uygulama bozuk" demekti:

Ne fark ettimNe yaptım
Backend lastChunk döndürüyor, istemci lastChunkId arıyordu: kanal sayfası kendini sonsuza kadar yeniden çekiyor, mesajlar hiç görünmüyorduKanal ve DM sayfalarını tek bir useChannelMessages hook'unda birleştirdim
includes(server.ownerId) ObjectId'leri referansla karşılaştırıyordu; altındaki kontrol de tersine çevrilmişti. Kanalı açan kişi üyelere iki kez ekleniyorduKimlikleri değerle karşılaştırdım; davet kabul eden kişiyi sunucunun mevcut kanallarına da ekledim
Bir arkadaşlık isteğini kabul etmek her seferinde yeni bir DM açıyorduVar olan birebir DM yeniden kullanılıyor
Soundboard'da tek bir tık, sesi üç kez çalıyordu: sunucu her çalmaya yeni bir kimlik verdiği için LiveKit veri kanalından ve socket'ten gelen kopyalar tanınmıyorduÇalma kimliği istemcide üretiliyor ve sunucu onu koruyor; ses yalnızca o kanaldakilere çalıyor
Soundboard'un REST handler'ları kullanıcıyı req.user.id'den okuyordu, oysa kimlik doğrulama req.user.userId yazıyordu: listeleme, yükleme, silme ve çalma hep başarısızdıAlanı düzelttim; 14 API kontrolüyle doğruladım
Geliştirme ortamında Vite'ta /livekit için WebSocket proxy'si yoktu: LiveKit el sıkışması yükseltme yerine 200 ve bir HTML sayfası alıyorduProxy'yi ekledim; Firefox yerel ICE adaylarını açsın diye bağlanmadan önce mikrofon izni isteniyor
Google, Tenor API'sini 30 Haziran 2026'da kapattı ve GIF seçici çalışmaz hale geldiKLIPY'ye geçtim. KLIPY'nin şartları GIF dosyalarını saklamayı yasakladığı için favoriler yalnızca referans tutuyor; dosya olarak yüklenen GIF'lerin linkleri 24 saatte dolduğu için onların kopyası IndexedDB'de duruyor

Şu anki durum, açık açık

  • Otomatik test paketi yok. Değişiklikler çalışan backend'e karşı API ve socket kontrolleriyle (örneğin güvenlik dalında 45/45), tarayıcıda ve iki masaüstü istemcide sınandı, ama bu kontroller repoya girmedi. Bir regresyonu yakalayacak kalıcı bir ağ henüz yok.
  • Üretimde sesli görüşme henüz iki istemciyle denenmedi. Son güvenlik sertleştirmesi merge edildi, sunucuya dağıtılması ve masaüstü uygulamasının davet kodu alanıyla yeniden derlenmesi bekliyor.
  • Her şey tek bir süreçte. Socket'ler, aramalar ve hız sınırı sayaçları bellekte duruyor. Birden fazla backend örneği çalıştırmak için Socket.IO'nun Redis adaptörü ve paylaşılan bir durum gerekecek.
  • Otomatik yedek yok. Şimdilik bilerek verdiğim bir karar; elle mongodump komutları dokümanda duruyor.
  • İstemci build'i tip kontrolü yapmıyor. vite build geçiyor, ama tsc çoğu kullanılmayan import'lardan oluşan hatalar raporluyor; bunlardan biri gerçek bir hataydı ve düzeltildi.

Yapay zekâyla nasıl çalıştım

Ekim 2026'daki canlandırmada iki yapay zekâ kodlama ajanıyla, Claude Code ve Antigravity ile, aynı anda ve aynı çalışma kopyalarında çalıştım. Bu, kendi başına bir mühendislik problemi çıkardı: iki ajan aynı dosyayı düzenleyebilir, birinin yarım işini diğeri "temizleyebilir", biri dal değiştirdiğinde diğerinin altındaki dosyalar değişebilir.

Bunun için projeye açık kurallar koydum:

  1. Bir dosyayı düzenlemeden hemen önce yeniden oku. Küçük, hedefli düzenlemeler yap; bütün dosyayı yeniden yazma.
  2. Senin yapmadığın değişiklikler geri alınacak şeyler değil. Beklemediğin bir düzenleme işini bozuyorsa dur ve bana sor.
  3. checkout, reset, stash, dal değiştirme yok. Commit'te yalnızca kendi değiştirdiğin dosyaları stage'le.
  4. Bir süreç ya da port başlatmadan önce kontrol et; başlatmadığın süreci öldürme.
  5. Her görevi TODO.md'de üstlen ve kapat: hangi ajan, hangi oturum, ne zaman, nasıl doğrulandı.
  6. Bağımlılık eklemeden önce bana söyle. Örneğin ekran paylaşımı sesi için yerel bir modül olan loopback-capture, benim onayımla eklendi.

Sonuçta TODO.md tarihli bir karar günlüğüne dönüştü: neyin kim tarafından yapıldığı, nasıl doğrulandığı ve neyin eksik kaldığı tek bir yerde. Güvenlik değişiklikleri bile, diğer ajanın yarım işi içine karışmasın diye origin/main'den açılan ayrı bir dalda, yalnızca kendi değişiklikleriyle commit'lendi.

Ürün ve risk kararları hep bende kaldı: dağıtım hedefi (Hetzner yerine mevcut ücretsiz sunucu), otomatik yedeğin şimdilik olmaması, yeni arayüz tasarımının onayı, ne zaman neyin dağıtılacağı. Bazı doğrulamaları da ancak ben yapabilirdim: sese katılma ve ayrılma seslerini kulakla dinledim, ekran paylaşımını ikinci bir istemciden izledim.

Bir sonraki projeye götüreceğim dersler

  • Bir yıl önce yazılmış kod "neredeyse bitmiş" değildir. İlk iş, uygulamayı gerçekten çalıştırıp hata listesini çıkarmaktı; mesajların hiç görünmediğini ancak böyle gördüm.
  • Hatalar sınırlarda saklanır. Chunk tasarımı doğruydu; sorunlar chunk'ın dolduğu anda ortaya çıkıyordu. Test verisini, o sınırı bilerek ve defalarca aşacak şekilde kurmak gerekiyor.
  • Yetki kontrolü bir kural olmalı, bir alışkanlık değil. Her endpoint'e ayrı ayrı eklenen kontroller er geç birini atlar.
  • Aynı olayı iki kanal taşıyorsa, tekilleştirme kimliği istemcide doğmalı. Sunucunun ürettiği bir kimlik, kopyaları ancak sunucunun gördüğü yerde tanır.
  • Paylaşımlı donanımda kaynak sınırı nezaket değil, gerekliliktir.
  • Birden fazla ajanla çalışırken asıl iş koordinasyondur. Yazılı kurallar ve ortak bir kayıt olmadan, iki ajanın hızı birbirinin işini bozmaya harcanır.