Nuvaka › Geliştirici belgeleri › Gerçek zamanlı odalar

Gerçek zamanlı odalar: nuvaka.realtime

Kullanıcılar arasında anlık veri alışverişi: online oyunlar, ortak tahta, birlikte düzenleme. Bir kullanıcı oda kurar, diğerleri 6 karakterlik kodla ya da arkadaş davetiyle katılır; odadakiler birbirine mesaj gönderir ve sürümlü bir ortak durumu paylaşır. Sunucu yalnız aktarır, mesaj saklamaz.

Sürüm: Nuvaka 0.3.1 ve geliştirici paketinde SDK 1.2.0 ile gelir. Manifestte "minAppVersion": "0.3.1" yaz.

Oda mı, uzantı sunucusu mu?

Gerçek zamanlı odaUzantı sunucusu
KodYok; kararları istemciler verir, Nuvaka yalnız aktarırPaketindeki sunucu kodu Nuvaka'nın makinesinde çalışır, kararı o verir
ÖmürBellekte; boş kalınca ya da en geç 24 saatte kapanırYayıncı sunucuları sürekli açık; kullanıcı sunucuları en çok 12 saat
Kalıcı veriYok (paylaşılan durum oda kapanınca gider)Uzantı başına SQLite (nuvaka.db)
İncelemeNormal kurallarserver olan her sürüm incelenir
UygunArkadaşlarla küçük oyunlar, ortak tahta, birlikte düzenleme7/24 açık dünyalar, hile yapılamayan oyunlar, çok oyunculu lobiler

İki özellik de aynı izni (nuvaka.realtime) ve uygulamanın aynı gerçek zamanlı bağlantısını kullanır.

İzin

"permissions": [
  { "nuvaka.realtime": true, "reason": { "tr": "Arkadaşlarınla aynı odada oynamak için", "en": "To play with your friends in the same room" } }
]
  • Değer yalnız true olabilir. Risk orta, denetim sunucuda. Kullanıcının gördüğü: "Seçtiğin kişilerle anlık veri alışverişi yapar (oyun odası, ortak çalışma)".
  • Okuma izinlerinden biriyle (files.read, nuvaka.cloud.read, nuvaka.notes.read …) birlikte istenirse kurulumda sızıntı uyarısı çıkar: "… ve bunları odadaki diğer kişilere gönderebilir".
  • Kodda nuvaka.realtime (ya da nuvaka.servers) geçip izin istenmezse nuvaka-ext lint ve yükleme reddeder.
  • Odaları yalnız uygulamanın kendisi kullanabilir: başka bir uzantının gizli kopyası (uses bağımlılığı) olarak çalışırken çağrılar 403 not_for_copies döner. Oda tek bir uzantıya aittir; başka uzantı o odayı göremez.
  • Güvenli oturumda (Güvenli Alan dosyası açıkken) kapalıdır: secure_session.

Nerede çalışır

ÇağrılarArayüzArka plan
create, join, get, invite, setState, update, leave✓✓
connect, send, sendTo✓ui_only

Bağlantıyı uygulamanın kabuğu tutar. Arayüzde create ve join odaya kendiliğinden bağlanır; olaylar gelmeye başlar. Arka planda oda kurulur ya da durum yazılır ama mesaj alınıp gönderilmez.

Oda kurmak

const room = await nuvaka.realtime.create({ maxMembers: 2, joinByCode: true, meta: { game: 'xox' } })
showCode(room.code)   // ör. "K7Q2MX"; yalnız kurucuya görünür
SeçenekKural
maxMembers2–16, varsayılan 8
joinByCodekodla katılma açık mı; varsayılan true. false ise yalnız davetle girilir.
metaodaya iliştirilen küçük bilgi, en çok 1 KB JSON

Kişi başına aynı anda en çok 5 oda olabilir; fazlası too_many_rooms (429). Çağrılar bir oda görünümü döner:

AlanAçıklama
roomIdodanın kimliği
extensionIdodanın ait olduğu uzantı
maxMembers, metakurulurken verilenler
code6 karakterlik katılma kodu (büyük harf ve rakam); yalnız kurucuda dolu, diğerlerinde null
isCreatorodayı sen mi kurdun
members[{ username, displayName, online }]
state, stateVersionpaylaşılan durum ve sürümü; yeni odada null ve 0
createdAtkuruluş zamanı (ISO)

nuvaka.realtime.get(roomId) güncel görünümü okur. Yalnız üye ya da davetli görebilir (not_member); oda yoksa room_not_found.

Kodla katılmak

const room = await nuvaka.realtime.join('k7q2mx')   // büyük/küçük harf fark etmez

Kod yalnız bu uzantının odası için geçerlidir. Geçersiz kod ya da kapanmış oda room_not_found, dolu oda room_full (409) verir. Oda kapanınca kod da geçersiz olur.

Arkadaş davet etmek

await nuvaka.realtime.invite(room.roomId, 'veli')
  • Yalnız arkadaşlar davet edilebilir (not_friend, 403). Arkadaşında uygulaman kurulu olmalı (not_installed, 409). Davet eden odanın üyesi ya da davetlisi olmalı (not_member).
  • Bir odada davetli ve üyelerin toplamı maxMembers'ın iki katını geçemez (too_many_invites).
  • Davet edilen, kendi dilinde (Türkçe/İngilizce) bir ext_realtime_invite bildirimi alır. Bildirimin bağlantısı /apps?id=<uzantı>&room=<roomId> biçimindedir: tıklayınca uygulaman açılır ve bağlamda ctx.realtimeRoom gelir.
  • Davetli üye olmadan önce connect ile girer. Uygulaman zaten açıkken davete tıklanırsa onInvite çalışır.
  • Arkadaş listesini kendi arayüzünde göstermek istersen ayrıca nuvaka.friends okuma izni gerekir; invite için gerekmez.
nuvaka.ready.then(async () => {
  const id = nuvaka.realtime.invitedRoom()          // ctx.realtimeRoom; davetle açılmadıysa null
  if (id) enter(await nuvaka.realtime.connect(id))
})
nuvaka.realtime.onInvite(async ({ roomId }) => enter(await nuvaka.realtime.connect(roomId)))

Üyeler

Odadakiler birbirinin yalnız kullanıcı adını ve görünen adını görür; ikisi de sunucudan gelir. Profili gizli olanın displayName değeri null'dır. E-posta ve kullanıcı kimliği paylaşılmaz.

nuvaka.realtime.onMember(({ roomId, type, username, displayName }) => {
  // type: 'joined' (katıldı) | 'offline' (bağlantısı koptu, üyeliği sürer) | 'left' (ayrıldı)
})

Mesajlar

await nuvaka.realtime.send(roomId, { t: 'emoji', v: '👍' })        // odadaki diğerlerine
await nuvaka.realtime.sendTo(roomId, 'veli', { t: 'hint', v: 3 })  // tek kişiye

nuvaka.realtime.onMessage(({ roomId, from, data, to, ts }) => {
  // from: { username, displayName }; to: tek kişiye gönderildiyse kullanıcı adı, değilse null; ts: ms
})
  • data herhangi bir JSON değeri olabilir, en çok 16 KB (message_too_large).
  • Kişi başına bir odada saniyede 20 mesaj gönderilebilir, kısa süreli 40'a kadar patlama serbesttir; aşımda rate_limited. Hareket gibi sık güncellemeleri birleştirip seyrek gönder.
  • Göndermek için odaya bağlı olmalısın (not_joined, not_connected); sendTo hedefi odada değilse member_not_found.
  • Mesaj içerikleri sunucuda saklanmaz ve loglanmaz. Sonradan katılan eski mesajları göremez: geç katılanın bilmesi gerekeni paylaşılan duruma koy.

Paylaşılan durum ve ifVersion

Her odanın tek bir paylaşılan durumu vardır (JSON, en çok 64 KB; aşarsa state_too_large). Durum depolamadaki gibi MVCC ile yazılır: her yazma okuduğun stateVersion'ı taşır, sunucudaki sürüm değiştiyse yazma reddedilir. Böylece iki oyuncu aynı anda hamle yaparsa biri diğerinin hamlesini ezmez.

// Doğrudan: okunan sürümle yaz
const room = await nuvaka.realtime.get(roomId)
try {
  const { stateVersion } = await nuvaka.realtime.setState(roomId, next, { ifVersion: room.stateVersion })
} catch (e) {
  if (e instanceof VersionConflictError) {
    // e.current: güncel stateVersion, e.value: güncel durum → yeniden hesapla
  }
}

// Kolay yol: oku → değiştir → yaz; çakışınca tazesiyle yeniden dener (varsayılan 5 deneme)
await nuvaka.realtime.update(roomId, (state, room) => {
  if (!state || state.turn !== mine) return undefined   // undefined: hiçbir şey yazma
  return { ...state, turn: other }
})
  • ifVersion zorunludur: verilmezse if_version_required (428). Yeni odada sürüm 0'dır, her başarılı yazma bir artırır.
  • Durumu yalnız odanın üyeleri yazabilir (not_member).
  • Her değişiklik odaya bağlı herkese, yazan dahil, realtime.state olayıyla gider: { roomId, stateVersion, state, by }. Arayüzü bu olaydan çiz; yazma sonucunu ayrıca çizmene gerek yok.
  • update işlevi aynı çağrıda birkaç kez çalışabilir; yan etkisiz tut.
nuvaka.realtime.onState(({ roomId, stateVersion, state, by }) => render(state))

Ayrılmak ve odanın kapanması

  • nuvaka.realtime.leave(roomId) → { success, closed }. Kurucu ayrılırsa ya da oda boşalırsa oda kapanır ve kod geçersiz olur.
  • Odalar sunucunun belleğinde tutulur. Kimse bağlı değilken 30 dakika sonra, her durumda en geç 24 saatte kapanır. Sunucu yeniden başlarsa odalar kapanır.
  • Kapanınca bağlı herkese realtime.closed { roomId } gider. Bağlantı dönünce oda bulunamazsa olay reason taşır (room_not_found, not_member, room_full).
  • room_not_found alınca yeni oda kur; eski odayı geri getirmeye çalışma.

Bağlantı durumu

nuvaka.realtime.onStatus(({ connected, reconnecting, error }) => {
  banner.textContent = connected ? '' : reconnecting ? 'Yeniden bağlanıyor…' : 'Bağlantı yok'
})

Bağlantı koparsa kabuk yeniden bağlanır ve odalara kendiliğinden yeniden girer; bu sırada reconnecting: true gelir. Aynı bağlantı uzantı sunucuları için de kullanılır.

Olaylar

Kısayollar nuvaka.realtime.onX(fn) dinlemeyi kaldıran bir işlev döner; nuvaka.on('realtime.message', fn) ile de dinlenebilir. Olaylar yalnız arayüze, yalnız bağlı olduğun odalar için gelir.

OlayKısayolYük
realtime.messageonMessage{ roomId, from: { username, displayName }, data, to, ts }
realtime.memberonMember{ roomId, type: 'joined' | 'offline' | 'left', username, displayName? }
realtime.stateonState{ roomId, stateVersion, state, by }
realtime.closedonClosed{ roomId, reason? }
realtime.statusonStatus{ connected, reconnecting?, error?: { code, message } }
realtime.inviteonInvite{ roomId } — uygulama açıkken davet bağlantısına tıklandı

Çağrılar

ÇağrıDönüş
create({ maxMembers?, joinByCode?, meta? })oda görünümü
join(code)oda görünümü
get(roomId)oda görünümü
invite(roomId, username){ success: true }
setState(roomId, state, { ifVersion }){ success: true, stateVersion }
update(roomId, fn, tries?){ stateVersion, state }
leave(roomId){ success: true, closed }
connect(roomId)oda görünümü; yalnız arayüz
send(roomId, data), sendTo(roomId, username, data)null; yalnız arayüz
invitedRoom()davet bağlantısıyla açıldıysa oda kimliği, değilse null

Hata kodları

KodAnlamı
permission_requirednuvaka.realtime izni yok
not_for_copies(403) gizli kopya olarak çalışan uzantı oda kullanamaz
secure_sessiongüvenli oturumda kapalı
room_not_found(404) oda yok, kapandı ya da kod geçersiz
not_member(403) odanın üyesi ya da davetlisi değilsin
room_full(409) oda maxMembers sınırında
too_many_rooms(429) aynı anda en çok 5 oda
not_friend(403) yalnız arkadaş davet edilebilir
not_installed(409) arkadaşında uygulama kurulu değil
too_many_invites(409) davetli + üye sayısı maxMembers'ın iki katında
if_version_required(428) setState sürümsüz çağrıldı
version_conflict(409) okunan sürüm eskidi; VersionConflictError, e.current güncel sürüm, e.value güncel durum
state_too_large(413) durum 64 KB'ı aşıyor
message_too_largemesaj 16 KB'ı aşıyor
rate_limitedsaniyede 20 mesaj sınırı (40 patlama) aşıldı
not_joined, not_connectedodaya ya da gerçek zamanlı bağlantıya bağlı değilsin
member_not_foundsendTo hedefi odada değil
ui_onlyconnect/send/sendTo arka planda çağrıldı

Sunucu uçları (başvuru)

SDK çağrıları uygulamanın uzantı token'ıyla şu uçlara gider; normalde doğrudan çağırmazsın.

UçSDK
POST /api/ext/v1/x/realtime/roomscreate
GET /api/ext/v1/x/realtime/rooms/{id}get
POST /api/ext/v1/x/realtime/join { code }join
POST /api/ext/v1/x/realtime/rooms/{id}/invite { username }invite
PUT /api/ext/v1/x/realtime/rooms/{id}/state { state, ifVersion }setState, update
POST /api/ext/v1/x/realtime/rooms/{id}/leaveleave
/api/ext/v1/x/realtime/hub (SignalR, JSON): Join, Send, SendTo, Leave; olaylar member, message, state, closedconnect, send, sendTo, olaylar

Örnek: iki kişilik XOX

Kurucu X, katılan O oynar. Tahta odanın paylaşılan durumundadır; hamleler update ile yazılır, herkes realtime.state olayından çizer. Emoji gibi geçici şeyler mesajla gider. render, showCode, showMenu, toast gibi arayüz işlevleri senindir.

{
  "id": "kullanici-adin.xox",
  "name": "XOX",
  "version": "1.0.0",
  "apiVersion": 1,
  "minAppVersion": "0.3.1",
  "description": { "tr": "Arkadaşınla iki kişilik XOX", "en": "Two-player tic-tac-toe with a friend" },
  "storeCategory": "games",
  "icon": "icon.svg",
  "entry": { "ui": "index.html" },
  "pages": [{ "id": "main", "title": { "tr": "XOX", "en": "Tic-tac-toe" } }],
  "permissions": [
    { "nuvaka.realtime": true, "reason": { "tr": "Arkadaşınla aynı odada oynamak için", "en": "To play with your friend in the same room" } }
  ]
}
// app.js
const LINES = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]]
const result = (b) => LINES.map(([a, c, d]) => (b[a] && b[a] === b[c] && b[a] === b[d] ? b[a] : null)).find(Boolean)
  ?? (b.every(Boolean) ? 'draw' : null)
const fresh = () => ({ board: Array(9).fill(null), turn: 'X', result: null })

let roomId = null
let mine = null   // 'X' | 'O'

function enter(room) {
  roomId = room.roomId
  mine = room.isCreator ? 'X' : 'O'
  render(room.state)                 // null ise "rakip bekleniyor"
}

// Yeni oyun: oda kur, kodu göster, boş tahtayı yaz (yeni odada stateVersion 0)
async function host() {
  const room = await nuvaka.realtime.create({ maxMembers: 2, meta: { game: 'xox' } })
  enter(room)
  showCode(room.code)
  await nuvaka.realtime.setState(room.roomId, fresh(), { ifVersion: room.stateVersion })
}

async function joinByCode(code) {
  try { enter(await nuvaka.realtime.join(code)) }
  catch (e) { toast(e.code === 'room_full' ? 'Oda dolu' : 'Kod geçersiz ya da oda kapandı') }
}

// Hamle: sıra bendeyse ve kare boşsa yaz. Rakip araya girdiyse update tazesiyle yeniden dener.
async function play(i) {
  await nuvaka.realtime.update(roomId, (s) => {
    if (!s || s.result || s.turn !== mine || s.board[i]) return undefined
    const board = s.board.slice()
    board[i] = mine
    return { board, turn: mine === 'X' ? 'O' : 'X', result: result(board) }
  })
}
const rematch = () => nuvaka.realtime.update(roomId, (s) => (s?.result ? fresh() : undefined))
const sendEmoji = (v) => nuvaka.realtime.send(roomId, { t: 'emoji', v })
const inviteFriend = (username) => nuvaka.realtime.invite(roomId, username)

nuvaka.realtime.onState(({ roomId: id, state }) => { if (id === roomId) render(state) })
nuvaka.realtime.onMessage(({ roomId: id, from, data }) => {
  if (id === roomId && data?.t === 'emoji') toast(`${from.displayName ?? from.username}: ${data.v}`)
})
nuvaka.realtime.onMember(({ roomId: id, type, username }) => {
  if (id === roomId && type === 'left') toast(`${username} ayrıldı`)
})
nuvaka.realtime.onClosed(({ roomId: id }) => { if (id === roomId) { roomId = null; showMenu('Oda kapandı') } })
nuvaka.realtime.onInvite(async ({ roomId: id }) => enter(await nuvaka.realtime.connect(id)))

nuvaka.ready.then(async () => {
  const invited = nuvaka.realtime.invitedRoom()     // bildirimden açıldıysa doğrudan gir
  if (!invited) return showMenu()
  try { enter(await nuvaka.realtime.connect(invited)) }
  catch (e) { showMenu(e.code === 'room_not_found' ? 'Oda kapanmış' : e.code) }
})

Kararları istemciler verdiği için bu yapı arkadaşlar arası oyunlara uygundur. Hile yapılamaması gereken oyunlarda kararı uzantı sunucusuna bırak.

Test: sahte nuvaka

@nuvaka/extension-sdk/testing odaları bellekte taklit eder: katılma kodu, üye sınırı, 5 oda, yalnız arkadaşa davet, MVCC, boyut sınırları ve gönderim hızı. Karşı tarafı m.peer ile oynatırsın.

AraçAçıklama
realtime: { username, displayName, friends, friendsWithoutApp, invitedRoom }seçenek: bu kullanıcı (varsayılan ben), davet edilebilen arkadaşlar, uygulaması olmayan arkadaşlar (not_installed), davet bağlantısıyla açılmış gibi
m.peer.createRoom(username, opts?)başkasının kurduğu oda: { roomId, code }
m.peer.join / send / setState / offline / leavebaşka bir üye katılır, mesaj gönderir, durumu yazar, bağlantısı kopar, ayrılır
m.peer.invite(roomId, { open? })bu kullanıcıyı davet etmiş gibi; open: true açık sayfaya realtime.invite gönderir
m.peer.close(roomId), m.peer.room(roomId)oda kapanır / odanın hâli
m.log.realtime, m.log.invitesgönderilen mesajlar (to: null = herkese) ve davetler
import { it, expect, afterEach } from 'vitest'
import { installMockNuvaka, uninstallMockNuvaka } from '@nuvaka/extension-sdk/testing'
import { VersionConflictError } from '@nuvaka/extension-sdk'

afterEach(() => uninstallMockNuvaka())

it('rakip hamlesi, sürüm çakışması ve davet', async () => {
  const m = installMockNuvaka({ grants: ['nuvaka.realtime'], realtime: { username: 'ayse', friends: ['veli'] } })
  const room = await nuvaka.realtime.create({ maxMembers: 2 })
  expect(room.code).toMatch(/^[A-Z0-9]{6}$/)
  await nuvaka.realtime.setState(room.roomId, { board: Array(9).fill(null), turn: 'X', result: null }, { ifVersion: 0 })

  const by = []
  nuvaka.realtime.onState((e) => by.push(e.by))
  await m.peer.join(room.roomId, 'veli')
  const v = await m.peer.setState(room.roomId, 'veli', { board: ['O', null, null, null, null, null, null, null, null], turn: 'X', result: null })
  expect(v).toBe(2)
  expect(by).toEqual(['veli'])

  // okuduğum sürüm (1) eskidi
  await expect(nuvaka.realtime.setState(room.roomId, {}, { ifVersion: 1 })).rejects.toBeInstanceOf(VersionConflictError)

  await expect(nuvaka.realtime.invite(room.roomId, 'ali')).rejects.toMatchObject({ code: 'not_friend' })
  await nuvaka.realtime.send(room.roomId, { t: 'emoji', v: '👍' })
  expect(m.log.realtime[0]).toMatchObject({ to: null, data: { t: 'emoji', v: '👍' } })
})

Gizlilik

  • Odadakiler birbirinin yalnız kullanıcı adını ve görünen adını görür; e-posta ve kimlik paylaşılmaz.
  • Mesaj içerikleri saklanmaz ve loglanmaz. Paylaşılan durum yalnız oda açıkken bellekte durur.
  • Kullanıcıya odada ne paylaşıldığını açıkça göster; okuma izinleriyle birlikte istediğin nuvaka.realtime için gerekçeyi net yaz.

Nuvaka Apps API v1 · son güncelleme 2026-09-29