Ana içeriğe geç
Ahmet Faruk UzunkayaYaratıcı geliştirici
Tüm yazılar
DerinlemesineMühendislik7 dk

React Native'de RTCPeerConnection yoktu, ben de sesi gizli bir WebView'a taşıdım

Dialoq'ta ses, kullanıcının hiç görmediği 1×1 piksellik bir WebView'da (web'de bir iframe'de) akıyor. Sayfa yüklenmeden gelen sinyaller kaybolmasın diye üç kuyruk kurdum, odalarda offer'ı kimin göndereceğini sabit bir kurala bağladım ve sunucuyu 44 backend testiyle sınadım.

  • React Native
  • WebRTC
  • Socket.IO
  • Node.js
React Native'de RTCPeerConnection yoktu, ben de sesi gizli bir WebView'a taşıdım

Dialoq'ta iki kişi, mesleğe göre filtrelenen bir kuyrukta eşleşiyor. İkisi de 10 saniye içinde onaylarsa 45 saniyelik bir sesli görüşme başlıyor. Bir de ses odaları var; dinleyiciler el kaldırıp kürsüye çıkabiliyor. İstemciyi Expo ile React Native'de yazdım. Arkada Express ve Socket.IO çalıştıran, bütün durumu bellekte tutan küçük bir Node.js sunucusu duruyor. Projenin adı bir süre Vadiden'di; AsyncStorage'daki eski vadiden: anahtarlarını hâlâ yeni adlara taşıyorum. İşin en hassas yeri ses. React Native'de yerleşik bir RTCPeerConnection yok. Üstelik sunucudan gelen bir WebRTC offer'ı, sesi işleyecek sayfa daha yüklenmeden telefona ulaşabiliyor.

Sesi kimsenin görmediği bir sayfada çalıştırmak

Projede native bir WebRTC modülü yok. Nedenini src/components/AudioBridge.tsx'in başındaki yoruma yazdım: React Native'de yerleşik RTCPeerConnection olmadığı için ses, platformun tarayıcı motorunda çalışıyor. Ses motoru dediğim şey, react-native-webview içinde açılan bir HTML sayfası. WebView 1×1 piksel boyutunda ve opacity: 0, yani kullanıcı onu hiç görmüyor. Web derlemesinde aynı HTML'i, srcDoc ile doldurduğum gizli bir iframe'de çalıştırıyorum.

İki ortamın da uyduğu sözleşme küçük (src/components/AudioBridge.types.ts): köprünün tek bir metodu var, inject(script). Uygulama sayfaya JSON mesajı yollamak yerine, sayfanın açtığı global fonksiyonları çağıran kısa betikler gönderiyor: window.handleRemoteSignal(...), window.updateRoom(...), window.setMuted(...). Native tarafta bu işi injectJavaScript yapıyor:

useImperativeHandle(
  ref,
  () => ({
    inject(script: string) {
      if (readyRef.current && webViewRef.current) {
        webViewRef.current.injectJavaScript(`${script};true;`);
      } else {
        queueRef.current.push(script);
      }
    },
  }),
  [],
);
 
const handleMessage = (event: WebViewMessageEvent) => {
  const message = parseBridgeMessage(event.nativeEvent.data);
  if (!message) return;
  if (message.type === READY_MESSAGE) {
    readyRef.current = true;
    const queued = queueRef.current.splice(0);
    queued.forEach((script) => webViewRef.current?.injectJavaScript(`${script};true;`));
    return;
  }
  onMessageRef.current(message);
};

READY_MESSAGE değeri "__ready". Sayfa bu mesajı gönderene kadar gelen her betik queueRef'te sırasını bekliyor. html değiştiğinde, yani yeni bir görüşme sayfası yüklendiğinde readyRef sıfırlanıyor. Betiğe gömdüğüm veriyi toScriptLiteral serileştiriyor; JSON.stringify çıktısında <, U+2028 ve U+2029 kaçırılıyor. Böylece bir SDP ya da kullanıcı adı betiğin sözdizimini bozamıyor.

Web tarafında (src/components/AudioBridge.web.tsx) uygulamadan sayfaya giden yön iframe.contentWindow.eval(script) ile işliyor. Dosyadaki yorumda da yazdığım gibi, bunu yapabilmemin sebebi iframe'in ebeveynle aynı origin'de olması. Ters yön için <head>'e tek satırlık bir shim ekliyorum:

const SHIM = `<script>window.ReactNativeWebView={postMessage:function(d){parent.postMessage({__dialoqBridge:true,data:d},"*");}};</script>`;
// ...
const srcDoc = useMemo(() => html.replace(/<head>/i, `<head>${SHIM}`), [html]);

WebRTC sayfası hangi platformda çalıştığını bilmiyor; iki ortamda da aynı şekilde window.ReactNativeWebView.postMessage çağırıyor. Ebeveyndeki dinleyici bir mesajı yalnızca iki şart tutarsa kabul ediyor: event.source iframe'in kendi penceresi olmalı ve mesaj __dialoqBridge işaretini taşımalı. Eski adın izi burada da var: dinleyici __vadidenBridge işaretli mesajları da geri çevirmiyor.

Erken gelen sinyali kaybetmemek

Köprüdeki kuyruk ilk katman. Bire bir görüşme sayfası (src/utils/callWebRtcHtml.ts), mikrofonu açan init() bitmeden __ready'yi gönderiyor. Yani sayfa betik kabul etmeye başlıyor ama RTCPeerConnection henüz yok, çünkü getUserMedia kullanıcının iznini bekliyor olabilir. Bu arada gelen sinyaller ikinci bir kuyruğa giriyor:

window.handleRemoteSignal = function (message) {
  if (!pc) { earlySignals.push(message); return; }
  enqueue(function () { return handleSignal(message.signal); });
};
// ...
async function init() {
  // ...
  earlySignals.splice(0).forEach(window.handleRemoteSignal);
  send({ type: 'ready' });
}
// ...
post('__ready');
init().catch(function (err) { post('webrtc-error', { message: errorMessage(err) }); });

Üçüncü katman pendingCandidates. remoteDescription ayarlanmadan gelen ICE candidate'lar bir dizide bekliyor, offer ya da answer işlendikten sonra da flushCandidates() onları ekliyor. Sinyallerin hepsi enqueue ile tek bir promise zincirinden geçiyor. Bu yüzden bir offer'ın setRemoteDescription'ı bitmeden arkasından gelen candidate'a sıra gelmiyor. Zincirde bir hata çıkarsa webrtc-error olarak uygulamaya bildiriliyor, zincir de kopmuyor.

Mikrofon izni reddedilirse görüşmeyi kesmiyorum. Hata mic-error olarak bildiriliyor, bağlantı bu sefer addTransceiver('audio', { direction: 'recvonly' }) ile kuruluyor ve kullanıcı en azından karşı tarafı duyabiliyor.

Bu kuyruklar yalnızca bir sayfanın içinde erken gelen sinyalleri koruyor. Karşı taraf daha dinlemeye başlamadan gönderilen sinyal için iki sayfa arasında ayrıca bir ready el sıkışması var; bunu köprünün __ready mesajıyla karıştırma:

if (signal.type === 'ready') {
  if (!CONFIG.isInitiator) {
    send({ type: 'ready' });
  } else if (!offerSent) {
    offerSent = true;
    await pc.setLocalDescription(await pc.createOffer());
    send({ type: 'offer', sdp: pc.localDescription });
  }
}

İki telefon da RTCPeerConnection hazır olunca ready gönderiyor. Offer göndermeyecek taraf her ready'ye ready ile cevap veriyor. Başlatan taraf ise ilk ready'ye bir kez, offer'la cevap veriyor. Diyelim ki başlatanın ready'si, karşı taraf ekranı açmadan gidip kayboldu. Karşı tarafın kendi ready'si sonradan geliyor ve offer'ı yine tetikliyor. Bu el sıkışmanın en sevdiğim yanı şu: hangi telefonun önce hazır olduğu sonucu değiştirmiyor.

Hangi süre nerede sayılıyor

Sunucu sesi taşımıyor; kuyruğu, önerileri ve oda durumunu tutup sinyalleri doğru kişiye iletiyor. Bir eşleşme baştan sona şöyle ilerliyor:

A, B    -> joinQueue { user, filter }
sunucu     runMatchmaking: iki yönlü filtre, ret beklemesi
sunucu  -> A: matchProposed { isInitiator: true,  expiresInMs: 10000 }
sunucu  -> B: matchProposed { isInitiator: false, expiresInMs: 10000 }
A       -> approveMatch        sunucu -> B: partnerApproved
B       -> approveMatch        sunucu -> A, B: matchApproved { callRoomId, isInitiator }
A <-> B    webrtc-signal: ready / offer / answer / candidate (sunucu partnere iletir)
A <-> B    P2P ses
10 sn dolarsa    sunucu -> A, B: matchRejected { reason: "timeout" }
biri reddederse  sunucu -> A, B: matchRejected { reason: "rejected" }

Eşleştirme (backend/src/server.ts) basit bir ilk-uyan taraması ve her joinQueue'da çalışıyor:

function runMatchmaking() {
  for (let i = 0; i < state.queue.length; i++) {
    const a = state.queue[i];
    for (let j = i + 1; j < state.queue.length; j++) {
      const b = state.queue[j];
      const compatible =
        filterAccepts(a.filter, b.user.profession) &&
        filterAccepts(b.filter, a.user.profession);
      if (!compatible || wasRecentlyRejected(a.socketId, b.socketId)) {
        continue;
      }
      state.queue.splice(j, 1);
      state.queue.splice(i, 1);
      proposeMatch(a, b);
      i--;
      break;
    }
  }
}

Filtre iki yönlü çalışıyor: A'nın filtresi B'nin mesleğini, B'ninki de A'nınkini kabul etmeli. "Herhangi Biri" joker değer. Kuyrukta önde duran a öneride user1 oluyor ve isInitiator: true alıyor. Bire bir görüşmede offer'ı bu bayrağı taşıyan taraf gönderiyor. Biri reddederse ikili, pairKey (sıralanmış iki socket id) ile 60 saniyelik bir listeye giriyor ve wasRecentlyRejected bu süre boyunca aynı çifti atlıyor.

Zamanlayıcıların bir kısmı sunucuda, bir kısmı istemcide:

SüreNeredeKaynakEtkisi
10 snSunucuproposalTimeoutMs, backend/src/server.tsOnaylanmayan öneri timeout nedeniyle düşüyor
60 snSunucurejectCooldownMs, aynı dosyaReddedilen socket id çifti yeniden eşleşmiyor
+30 snSunucucallExtensionSec, aynı dosyaextendCall iki tarafa callExtended gönderiyor
25 snİstemciCONNECT_TIMEOUT_MS, src/screens/CallScreen.tsxSes bağlanmadıysa görüşme "failed" ile bitiyor
45 snİstemciCALL_DURATION_SEC, src/context/AppContext.tsxSes bağlanınca başlayan sayaç, sıfırda "timeout"

Öneri ekranındaki geri sayım yalnızca ekranda göstermek için var; MatchScreen.tsx'teki yorum da asıl zaman aşımının sunucuda olduğunu söylüyor. Görüşme süresini ise sunucu zorlamıyor. 45 saniyelik sayaç köprüden audio-connected geldikten sonra işlemeye başlıyor, sıfıra inince de görüşmeyi istemci endCall ile bitiriyor. 25 saniyelik sınırın sebebi koddaki yorumda yazıyor: ses yolu o zamana kadar kurulmadıysa (engelli NAT, TURN yok) beklemeye değmez.

Odada offer'ı kim gönderecek

Odalarda topoloji değişiyor. Konuşmacılar arasında tam bir mesh var; her dinleyiciden her konuşmacıya da yalnızca alıcı bir bağlantı açılıyor. Mesh'te iki taraf aynı anda offer göndermesin diye her çiftte kimin göndereceğini sabit bir kural belirliyor (src/utils/roomWebRtcHtml.ts):

var isSpeakerId = function (id) { return speakers.indexOf(id) !== -1; };
var signatureFor = function (id) { return (amSpeaker ? 'S' : 'L') + (isSpeakerId(id) ? 'S' : 'L'); };
 
function iAmInitiator(peerId) {
  if (!amSpeaker) return true;
  if (!isSpeakerId(peerId)) return false;
  return CONFIG.me > peerId;
}

Dinleyici ile konuşmacı arasında offer'ı her zaman dinleyici gönderiyor; konuşmacılar dinleyicilerin peşine düşmüyor. İki konuşmacı arasında socket id'si büyük olan gönderiyor (CONFIG.me > peerId, düz string karşılaştırması). sync() yalnızca speakers listesini dolaştığı için dinleyiciler birbirine hiç bağlanmıyor. Dinleyici getUserMedia çağırmıyor, bağlantıyı recvonly bir transceiver ile kuruyor; mikrofonu ancak kürsüye çıkınca açılıyor.

Her bağlantı ayrıca rastgele bir oturum kimliği (sid) taşıyor. Bu kimliğin offer sırasıyla bir ilgisi yok. İşi, sid'i tutmayan answer, candidate ve bye sinyallerini elemek; eski bir oturumdan geç gelen sinyal yeni bağlantıyı bu sayede bozamıyor. signature (SS, SL, LS, LL) ise bağlantı kurulduğu andaki rolleri saklıyor. Biri kürsüye çıkınca ya da inince imza artık tutmuyor; bağlantı bye ile kapatılıp yeniden kuruluyor.

Kopan bağlantıları bir watchdog topluyor:

function handleBrokenPeer(peerId, entry) {
  if (peers.get(peerId) !== entry) return;
  closePeer(peerId, true);
  var count = (retries.get(peerId) || 0) + 1;
  retries.set(peerId, count);
  if (count <= MAX_RETRIES) {
    setTimeout(function () { enqueue(sync); }, 500 * count);
  }
}

Bir bağlantı 15 saniyede (CONNECT_TIMEOUT_MS = 15000) kurulamazsa yeniden kuruluyor. disconnected durumuna 5 saniye tanıyorum, failed ise hemen işleniyor. Peer başına en fazla 3 deneme var (MAX_RETRIES) ve bekleme 500 ms × deneme sayısı kadar uzuyor. Başarılı bir bağlantı ya da rol değişimi sayacı sıfırlıyor.

Moderatör için ayrı bir alan tutmadım; moderatör, speakers[0]. Odayı açan kişi doğrudan konuşmacı ve moderatör olarak giriyor. Var olan bir odaya joinVoiceRoom ile katılan herkes ise dinleyici olarak başlıyor. Kürsü boşken ilk isteyen doğrudan çıkıyor; sonrakiler el kaldırıyor ve onları onaylayabilen tek kişi speakers[0]. Moderatör ayrılınca dizi kayıyor ve sıradaki konuşmacı kendiliğinden moderatör oluyor. Kürsü 8 kişiyle sınırlı (maxSpeakers); kürsü doluyken gelen onay hiçbir uyarı vermeden boşa düşüyor.

İstemciden geleni olduğu gibi almıyorum

Her Socket.IO handler'ı aynı sarmalayıcıdan geçiyor:

const on = (event: string, handler: (payload: Payload) => void) => {
  socket.on(event, (raw: unknown) => {
    try {
      handler(isPlainObject(raw) ? raw : {});
    } catch (err) {
      logger.error(`Handler "${event}" failed for ${socket.id}:`, err);
    }
  });
};

Nesne olmayan bir payload {}'e dönüşüyor, handler'da fırlayan istisna da loglanıp yutuluyor. Doğrulamayı elle yazdım (backend/src/validation.ts). Backend'in bağımlılıkları da yalnızca cors, express ve socket.io'dan ibaret. sanitizeUser bir beyaz liste gibi çalışıyor: yalnızca name, avatar, profession, bio ve id kopyalanıyor, role: "admin" gibi fazladan alanlar düşüyor. Metinler de cleanString'den geçiyor:

export function cleanString(value: unknown, max: number): string {
  if (typeof value !== "string") return "";
  const normalized = value
    .replace(/[\u0000-\u001F\u007F]+/g, " ")
    .replace(/\s+/g, " ")
    .trim();
  return Array.from(normalized).slice(0, max).join("").trim();
}

Kesmeyi Array.from ile kod noktası bazında yapıyorum, bu yüzden bir emoji ortadan ikiye bölünmüyor. İsim 24, bio 160 kod noktasıyla sınırlı.

Görüşme olaylarını callContext çözüyor. Gönderen o görüşmenin iki katılımcısından biri değilse olay yok sayılıyor. İstemcinin gönderdiği targetId'yi bire bir görüşmede hiç kullanmıyorum; sinyal her zaman kayıtlı partnere gidiyor. Arkadaşlıkta da karşı tarafa, istemcinin kendisi hakkında söylediği profili değil, sunucuda kayıtlı olanı gönderiyorum. sendFriendRequest ve acceptFriendRequest aynı handler'a bağlı. Bu yüzden iki taraf da isterse iki telefona aynı anda friendRequestAccepted gidiyor; ikisi aynı anda bassa bile sonuç değişmiyor.

WebRTC sinyallerinin içeriğini ise doğrulamıyorum. isValidSignal yalnızca sinyalin bir nesne olduğuna ve type alanının string olduğuna bakıyor. Boyut sınırı Socket.IO'nun maxHttpBufferSize: 100_000 ayarından geliyor.

44 backend testi var, uçtan uca testler bir PR'da bekliyor

Backend testlerini node:test ve node:assert/strict ile, gerçek Socket.IO istemcileri kullanarak yazdım. Dağılım şöyle: calls 11, matchmaking 10, robustness 3, rooms 13, validation 7, toplam 44. Her test kendi sunucusunu listen(0) ile boş bir portta açıyor, süreleri de seçeneklerle kısaltıyorum (proposalTimeoutMs: 300, rejectCooldownMs: 500). targetId kuralını sınayan test (backend/test/calls.test.ts) bilerek yanlış bir hedef gönderiyor:

it("relays WebRTC signals only to the partner", async () => {
  const outsider = await h.client();
  const relayed = waitFor(b, "webrtc-signal");
  a.emit("webrtc-signal", { callRoomId, targetId: outsider.id, signal: { type: "offer", sdp: "x" } });
  const msg = await relayed;
  assert.equal(msg.senderId, a.id);
  assert.deepEqual(msg.signal, { type: "offer", sdp: "x" });
  assert.equal(await expectSilence(outsider, "webrtc-signal"), true);
});

Dayanıklılık testi 19 olayın her birine 12 bozuk payload yolluyor (null, diziler, { roomId: { $ne: 1 } }, { proposedMatchId: 1e9 } gibi). Ardından /status'un hâlâ 200 döndüğüne ve iki yeni istemcinin eşleşebildiğine bakıyor.

Uçtan uca testler ve CI şimdilik yalnızca PR #1'de. O PR, proje daha Vadiden adını taşırken açıldı ve master'a birleşmedi. PR'daki testlerde Playwright, Chromium'un sahte mikrofonuyla iki ayrı kullanıcı açıyor ve sesin gerçekten aktığını iframe'in içinden getStats ile ölçüyor. Testin geçmesi için inbound-rtp ses raporlarındaki bytesReceived toplamının 20 saniye içinde 1000 baytı geçmesi gerekiyor. master'daki playwright.config.ts ise var olmayan bir ./e2e klasörünü gösteriyor, yani PR birleşene kadar npm run test:e2e orada çalışmıyor. README'deki CI rozeti de hâlâ eski BoxuDev/vadiden reposuna bakıyor.

Eksik kalanlar

README'deki eksik listesi hâlâ geçerli. Backend her şeyi bellekte tutuyor; sunucu yeniden başlarsa kuyruk, görüşmeler ve kullanıcıların açtığı odalar sıfırlanıyor. TURN varsayılan olarak gelmiyor: yalnızca Google'ın iki STUN sunucusu tanımlı, TURN ancak EXPO_PUBLIC_TURN_URL ve kimlik bilgisi değişkenleri verilirse ekleniyor. Hesap sistemi yok, profil ve arkadaş listesi cihazda (AsyncStorage'da) duruyor. Mesh yüzünden kürsüyü 8 konuşmacıyla sınırladım; büyük odalar için bir SFU gerekecek. Girdi doğrulaması ve yetki kontrolleri var ama hız sınırlama ve raporlama yok. Koddan bir not daha düşeyim: ret beklemesi kişiye değil bağlantıya bağlı, çünkü anahtar iki socket id'den oluşuyor.

Projenin ayrıntılarına proje sayfasından, kaynak koduna GitHub'dan göz atabilirsin.