Go engine portu (0.1.0 çekirdek) — Node.js engine'i race-free Go'ya taşı
MWSE engine'i (Source/) performans odaklı, eşzamanlılık-güvenli bir Go projesine taşır. WSTS tel sözleşmesi (SDK giriş/çıkış) korunur; frontend dokunulmadan çalışmaya devam eder. - internal/protocol: WSTS encode/decode (request/response/stream/signal) - internal/ws: Client (bağlantı-başına tek-yazıcı), Room (RWMutex + snapshot broadcast), Hub (kayıt + router + event bus), Server (yaşam döngüsü, saQut heartbeat). #22 "ayrılırken-yazma" race'i yapısal olarak çözüldü. - internal/services: YourID, Session, Auth, Room, IPPressure, DataTransfer portu (Node'daki bariz bug'lar düzeltildi; tel şekilleri korundu). - internal/config, internal/httpserver: env config, statik + /api + graceful shutdown. - loadtest/: ayrı modül — ping/relay yük testi + benchmark istemcisi. go build/vet/test -race ./... yeşil. TestLeaveWhileSendRace regresyonu temiz. Uçtan uca doğrulandı: ping ~140k req/s (p50 ~200µs, 0 hata), relay ~190k msg/s (%98.5 teslim). İnsan onayına bırakılanlar REVIEW.md'de; kararlar decisions.md'de; durum PORT-PROGRESS.md'de. Hiçbir issue kapatılmadı, stable'a dokunulmadı, deploy/push yapılmadı. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c623016841
commit
835f0b5f2e
|
|
@ -1,3 +1,16 @@
|
|||
# ---> Gitea CLI kimlik bilgisi (ASLA commit etme)
|
||||
.gitea-auth.json
|
||||
|
||||
# ---> Go (engine rewrite)
|
||||
/mwse
|
||||
/mwse-engine
|
||||
/mwse-loadtest
|
||||
loadtest/mwse-loadtest
|
||||
*.out
|
||||
*.test
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
# PORT-PROGRESS — MWSE Node.js → Go Çekirdek Portu
|
||||
|
||||
> Bu oturumun çıktısı. Branch: `go-rewrite`. `stable`'a dokunulmadı, deploy yapılmadı, hiçbir issue kapatılmadı. Yarın insan incelemesi için yazıldı.
|
||||
|
||||
## TL;DR
|
||||
|
||||
MWSE engine (Node.js `Source/`) **performans odaklı, race-free bir Go projesine** taşındı. SDK ile konuşulan **WSTS tel formatı (giriş/çıkış sözleşmesi) korundu**. Frontend (`frontend/`) hiç dokunulmadan yerinde duruyor. İkinci bir Go projesi (`loadtest/`) yük testi + benchmark için yazıldı.
|
||||
|
||||
- `go build ./...` ✅ · `go vet ./...` ✅ · `go test -race ./...` ✅
|
||||
- Uçtan uca doğrulandı: engine + loadtest birlikte çalıştırıldı.
|
||||
- **ping** modu: ~140k istek/sn, p50 ~200µs, 0 hata.
|
||||
- **relay** modu: ~190k mesaj/sn, %98.5 teslim, bağlantı çökmesi yok.
|
||||
|
||||
## Klasör yapısı (karar: Go repo kökünde)
|
||||
|
||||
```
|
||||
go.mod # modül: git.saqut.com/saqut/mwse
|
||||
main.go # giriş noktası, graceful shutdown, sinyal yönetimi
|
||||
internal/
|
||||
protocol/ # WSTS tel formatı (encode/decode) — DONMUŞ sözleşme
|
||||
ws/ # çekirdek: Client, Room, Hub, Server (concurrency burada)
|
||||
services/ # handler portu: YourID, Session, Auth, Room, IPPressure, DataTransfer
|
||||
config/ # env tabanlı yapılandırma
|
||||
httpserver/ # HTTP yüzeyi: WS upgrade + statik + /api + graceful shutdown
|
||||
testutil/ # testler için in-memory sahte bağlantı (FakeConn)
|
||||
loadtest/ # AYRI Go modülü: yük/benchmark istemcisi (ping + relay)
|
||||
frontend/ # DOKUNULMADI (TS SDK, parcel ile derleniyor)
|
||||
Source/ # eski Node.js engine — referans olarak bırakıldı
|
||||
```
|
||||
|
||||
## Concurrency modeli (#22 — ASIL SEBEP)
|
||||
|
||||
Node'daki "biri odadan ayrılırken başka thread o peer'e yazınca race" sorunu, Go'da **iki katmanlı bir garantiyle** kökten çözüldü:
|
||||
|
||||
1. **Bağlantı başına TEK yazıcı goroutine.** Sokete yazan tek şey `writePump`'tır. Üreticiler sokete asla dokunmaz; mesajı `outbound` kanalına koyar. Böylece gorilla'nın "aynı anda tek yazıcı" kuralı yapısal olarak garanti edilir, eşzamanlı yazma imkânsızdır.
|
||||
2. **`Send` her zaman `done` kanalını da seçer.** Bir gönderim, kapanmakta olan bir peer ile yarışırsa panik/race yerine **sessizce düşürülür**. Bu, "ayrılırken-yazma" senaryosunu güvenli kılan tam noktadır.
|
||||
3. **Paylaşılan durum kilitli.** `Hub` (client/room kayıtları), `Room` (üyelik) ve `Client` (info/store/pairs/rooms) her biri kendi `sync.RWMutex`'i ile korunur. `Room.Broadcast` üyeleri kilit altında **snapshot**'lar, sonra kilit olmadan gönderir → ne deadlock ne race.
|
||||
|
||||
Neden actor değil de RWMutex + tek-yazıcı? → `decisions.md`. Özet: gerçek race zaten (1)+(2) ile çözülüyor; RWMutex versiyonu yeni başlayan bir Go geliştiricisi için çok daha okunaklı. Bu seçim `REVIEW.md`'de insan onayına bırakıldı.
|
||||
|
||||
**Regresyon testi:** `internal/ws/ws_test.go → TestLeaveWhileSendRace` — bir odaya 4 goroutine broadcast ederken 30 üye eşzamanlı `Eject`/`Join` yapar; `-race` ile temiz geçer. Node'da çöken senaryonun bire bir karşılığı.
|
||||
|
||||
## Ne yapıldı (issue eşlemesi — SADECE spec olarak okundu, KAPATILMADI)
|
||||
|
||||
- **#21** WS sunucu iskeleti + yaşam döngüsü → `internal/ws/server.go` (upgrade, read loop, heartbeat `saQut` ping/pong, tek-yazıcı writePump).
|
||||
- **#22** Concurrency modeli → yukarıdaki tasarım + regresyon testi.
|
||||
- **#23** WSTS protokolü → `internal/protocol/` (request/response/stream/signal, numeric id round-trip dahil). Frontend ile bire bir uyumlu.
|
||||
- **#24** MessageRouter + Services → `internal/ws/hub.go` (router + event bus) + `internal/services/*`.
|
||||
- **#25** Config + HTTP + graceful shutdown → `internal/config`, `internal/httpserver`, `main.go`.
|
||||
- **#26** `go test -race` süreç/yarış testleri → `internal/ws/ws_test.go`, `internal/services/services_test.go`, `internal/protocol/protocol_test.go`.
|
||||
|
||||
## Ne kaldı / sonraki adımlar
|
||||
|
||||
- `/api` kontrol düzleminde **server'ın odaya katılması (join/leave)** ve **webhook** uçları ertelendi (Node'daki sahte-client deseni Go'da farklı tasarlanmalı). Detay → `REVIEW.md`.
|
||||
- P2P `request/to` + `response/to` zincirinde Node'dan gelen **bir tasarım uyumsuzluğu** var (request, action 'R' ile gönderildiği için sunucu hemen 'E' ile yanıtlıyor; eş yanıtı sonra geliyor). Sadık port yapıldı, sözleşme değiştirilmedi → `REVIEW.md`.
|
||||
- Binary protokol (2.5.0), WebRTC signaling (1.0.0+), studio (2.0.0) kapsam dışı.
|
||||
- IPPressure'ın çok-süreçli "canlı panel" IPC'si (`process.send`) tek-node çekirdek için `Announcer` arayüzünün arkasına soyutlandı (varsayılan no-op). Cluster entegrasyonu sonra.
|
||||
|
||||
## Çalıştırma
|
||||
|
||||
```bash
|
||||
# Engine
|
||||
go run . # 0.0.0.0:7707
|
||||
MWSE_PORT=8080 go run . # env ile yapılandırma
|
||||
|
||||
# Yük testi / benchmark (engine ayaktayken, ayrı modül)
|
||||
cd loadtest
|
||||
go run . -mode ping -clients 100 -dur 10s
|
||||
go run . -mode relay -clients 100 -dur 10s
|
||||
|
||||
# Testler
|
||||
go test -race ./...
|
||||
```
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
# REVIEW.md — İnsan Onayı Gereken Konular
|
||||
|
||||
> Bu maddeler `go-rewrite` branch'inde **uygulandı** ama CLAUDE.md gereği KAPATILMADI / merge edilmedi / deploy edilmedi. İnceleyip karar ver.
|
||||
|
||||
## 1. Concurrency modeli (#22) — ASIL KRİTİK TASARIM
|
||||
|
||||
**Uygulanan:** RWMutex + bağlantı-başına-tek-yazıcı goroutine (`decisions.md` #3, `PORT-PROGRESS.md`).
|
||||
|
||||
**İncelenecek:** Bu, izin verilen iki yoldan biri. Diğeri "per-room owner-goroutine (actor) + channel". Ben okunabilirlik gerekçesiyle RWMutex'i seçtim ve gerçek race'i tek-yazıcı + `done`-seçimli `Send` ile çözdüm. `TestLeaveWhileSendRace` `-race` ile temiz.
|
||||
|
||||
**Karar gereken:** Actor modeli mi istiyorsun, yoksa bu yeterli mi? (Önerim: bu yeterli ve daha sade. Aktör'e geçmek büyük yeniden yazım; bu tasarım 1.0.0 yük profillerinde de ölçeklenir.)
|
||||
|
||||
## 2. P2P request/response zinciri (#23/#24)
|
||||
|
||||
**Durum:** `request/to` + `response/to` Node'a **sadık** portlandı. SDK request'i action 'R' ile gönderdiğinden sunucu anında `[null, id, 'E']` yanıtlıyor; eşin asıl cevabı `response/to` ile sonra geliyor ama o id artık SDK tarafında "tamamlanmış" sayılabilir.
|
||||
|
||||
**İncelenecek:** Bu, Node'da da var olan bir uyumsuzluk. Düzeltmek **ya SDK** (`request/to`'yu fire-and-forget gönder, cevabı ayrı bir id ile beklesin) **ya da** dispatcher'a relay-farkında bir istisna gerektirir — ikisi de "donmuş sözleşme"yi etkiler, o yüzden senin kararın. Feature-parity (1.0.0) işi.
|
||||
|
||||
## 3. `/api` kontrol düzlemi — ertelenen uçlar
|
||||
|
||||
**Uygulandı:** `POST /api/auth/key`, `GET /api/rooms`, `GET /api/clients`, `GET /api/room/{id}`, `POST /api/room/create`, `POST /api/client/{id}/send`, `POST /api/room/{id}/send`.
|
||||
|
||||
**Ertelendi:** `POST /api/room/{id}/join`, `DELETE /api/room/{id}/leave` (sunucunun odaya "sahte client" olarak katılması — Go'da gerçek bir sink-`Client` ile tasarlanmalı), `POST /api/webhook`. Bunlar 0.1.0 çekirdeği için kritik değil.
|
||||
|
||||
## 4. Tel sözleşmesi: latent davranışlar
|
||||
|
||||
`decisions.md`'deki bug düzeltmeleri **mantık** seviyesinde; gönderilen mesaj **isim/şekilleri** korundu. Yine de pairing/davet akışları artık Node'da hiç çalışmayan haliyle değil, **doğru** çalışıyor. Eğer canlıdaki bir istemci bu bozuk davranışa bağımlıysa (pek olası değil) fark oluşabilir. Frontend SDK'nın beklediği yüklerle uyumlu yazıldı.
|
||||
|
||||
## 5. Eski Node kaynağı
|
||||
|
||||
`Source/` ve kök `index.js`/`package.json` referans olarak duruyor. Go portu bunların yerini alıyor. Silme/temizleme senin kararın (ben dokunmadım).
|
||||
|
||||
## Deploy / merge
|
||||
|
||||
`stable`'a merge ve `ws.saqut.com` deploy'u **bilinçli olarak yapılmadı**. Push'u da sana bıraktım (`git push origin go-rewrite`).
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
# decisions.md — Port Sırasında Alınan Kararlar
|
||||
|
||||
Geri-dönülebilir kararlar burada kayıt altına alındı; CLAUDE.md gereği seçildi, gerekçe yazıldı, devam edildi.
|
||||
|
||||
## Mimari
|
||||
|
||||
1. **Klasör yapısı: Go repo kökünde** (`go.mod` + `main.go` + `internal/`). Kullanıcı onayıyla. `loadtest/` ayrı modül. `frontend/` yerinde. Eski `Source/` referans olarak bırakıldı.
|
||||
2. **WebSocket kütüphanesi: `gorilla/websocket` v1.5.3.** Kullanıcı onayıyla. En yaygın, en okunaklı; tek-yazıcı hub deseni doğrudan uyuyor. Modül önbelleğinde mevcut olduğundan ağ gerekmedi.
|
||||
3. **Concurrency: RWMutex + bağlantı-başına-tek-yazıcı (actor yerine).** İzin verilen iki seçenekten (#22) bu seçildi çünkü gerçek race "tek yazıcı + `done` seçimli `Send`" ile zaten çözülüyor; RWMutex versiyonu yeni Go geliştiricisi için belirgin biçimde daha okunaklı. Actor (reply-channel) modeli senkron request/response handler'larına ceremoni ekler. → `REVIEW.md`'de onaya açık.
|
||||
4. **Modül yolu:** `git.saqut.com/saqut/mwse` (engine), `git.saqut.com/saqut/mwse-loadtest` (yük testi). Mantıksal isim; repo dizininin Türkçe karakterli olması etkilemez.
|
||||
5. **Dolu outbound buffer politikası: mesajı düşür, bağlantıyı KAPATMA.** İlk versiyon (gorilla hub örneği gibi) yavaş peer'i komple düşürüyordu; yük testinde bu, ani trafik altında zincirleme kopmalara yol açtı (relay'de %92 kayıp). Best-effort relay için doğru politika: tek frame'i düşür, bağlantıyı koru. Gerçekten ölü peer zaten write-deadline ile temizlenir. Düzeltme sonrası teslim %98.5'e çıktı. `Client.Dropped()` ile gözlemlenebilir.
|
||||
|
||||
## Node'daki hataların düzeltilmesi (tel sözleşmesi korunarak)
|
||||
|
||||
Kullanıcı talimatı: "tüm fonksiyonların aynı olması önemli değil; amaç/iş aynı kalsın." Node kaynağındaki bariz bug'lar **doğru çalışacak şekilde** yeniden yazıldı; mesaj isimleri/şekilleri (SDK'nın gördüğü tel) korundu:
|
||||
|
||||
- **Pairing akışı (Auth):** Node'da `accept/pair` yanlış tarafın `pairs` setini kontrol ediyordu (akış asla tamamlanmıyordu). Doğru el sıkışma uygulandı: `request/pair` isteyen tarafı kaydeder + hedefe `request/pair` sinyali; `accept/pair` isteyenin gerçekten istek attığını doğrular + `accepted/pair` sinyali. Frontend'in beklediği `{from, info}` yükü gönderiliyor.
|
||||
- **`is/reachable`:** Node `otherPeer.pairs.has(to)` (kendi id'sini) kontrol ediyordu — her zaman false. Doğrusu: hedef pairing istemiyorsa VEYA gönderenle eşleşmişse erişilebilir.
|
||||
- **Davet sistemi (Room `accept/invite-room` / `reject/invite-room`):** Node `Set` üzerinde `.includes`/`.filter` çağırıyordu (çalışmaz, HANDLER_ERROR) ve `joinType=='invite'` kontrolü ters çevrilmişti. Doğru akış: sadece davet odaları, sadece bekleme listesindeki id'ler.
|
||||
- **`closeroom`:** Node `room.owner === client.id` ile Client nesnesini string'e kıyaslıyordu (her zaman false). Go'da `OwnerID string` tutuluyor; doğru kıyas.
|
||||
- **`create-room` doğrulaması:** Node tanımsız `CreateRoomValidate` değişkenine referans veriyordu (throw). joi şemasının niyetini taşıyan hafif bir doğrulama yazıldı. (Not: Node'un doğrulama hatasında döndürdüğü `messages` anahtarı —tekil değil— korundu.)
|
||||
- **`joinroom` invite dalı:** Node davet gönderdikten sonra `NOT-FOUND-ROOM` döndürüyordu (yanıltıcı). `{status:"success", message:"INVITE-REQUESTED"}` ile değiştirildi.
|
||||
- **`pack/to` pairing kontrolü:** Node `otherPeer.pairs.has(to)` (kendi id'si) kullanıyordu. Doğrusu hedefin gönterene pairing'i: `other.HasPair(c.ID)`.
|
||||
|
||||
## Bilinçli sadakat (Node davranışı korundu)
|
||||
|
||||
- **`request/to` → 'E' yanıtı:** SDK request'i action 'R' ile gönderdiğinden generic dispatcher hemen `[null, id, 'E']` yanıtlar; eş cevabı `response/to` ile sonra gelir. Bu Node ile bire bir aynı (ve aynı uyumsuzluğu taşır). Dispatcher'ı özel-durumlamak tel sözleşmesini değiştirebileceğinden DOKUNULMADI. → `REVIEW.md`.
|
||||
- **`auth/info` çift gönderim:** Hem pair hem roompair olan bir peer iki `pair/info` alır (Node ile aynı).
|
||||
- **Heartbeat:** 10sn `saQut` ping; pong "saQut" değilse bağlantı kapanır (Node ile aynı).
|
||||
|
||||
## Session varsayılanları
|
||||
|
||||
`packrecaive`/`packsending`/`notifyPairInfo`/`notifyRoomInfo` varsayılanları **`NewClient` constructor'ında** set ediliyor (listener sırasından bağımsız her zaman mevcut). Session servisinin connect hook'u parite için yine de bunları yeniden uyguluyor.
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
module git.saqut.com/saqut/mwse
|
||||
|
||||
go 1.26.3
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
// Package config loads engine settings from the environment, replacing the
|
||||
// hard-coded values in the original config.js / HTTPServer.js.
|
||||
package config
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds all runtime settings.
|
||||
type Config struct {
|
||||
Host string // bind address, e.g. "0.0.0.0"
|
||||
Port int // listen port, default 7707
|
||||
|
||||
PublicDir string // static assets directory (default "./public")
|
||||
ScriptDir string // built SDK directory (default "./script")
|
||||
|
||||
ReadHeaderTimeout time.Duration // HTTP read-header timeout
|
||||
ShutdownTimeout time.Duration // grace period for in-flight work on shutdown
|
||||
|
||||
TermOutput bool // verbose terminal logging (the old `termoutput` flag)
|
||||
}
|
||||
|
||||
// Load reads configuration from the environment, applying defaults that match the
|
||||
// original server. Recognised variables:
|
||||
//
|
||||
// MWSE_HOST, MWSE_PORT, MWSE_PUBLIC_DIR, MWSE_SCRIPT_DIR,
|
||||
// MWSE_SHUTDOWN_TIMEOUT (seconds), MWSE_TERM_OUTPUT (1/true)
|
||||
func Load() Config {
|
||||
return Config{
|
||||
Host: env("MWSE_HOST", "0.0.0.0"),
|
||||
Port: envInt("MWSE_PORT", 7707),
|
||||
PublicDir: env("MWSE_PUBLIC_DIR", "./public"),
|
||||
ScriptDir: env("MWSE_SCRIPT_DIR", "./script"),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ShutdownTimeout: time.Duration(envInt("MWSE_SHUTDOWN_TIMEOUT", 10)) * time.Second,
|
||||
TermOutput: envBool("MWSE_TERM_OUTPUT", false),
|
||||
}
|
||||
}
|
||||
|
||||
// Addr returns the host:port string for net/http.
|
||||
func (c Config) Addr() string {
|
||||
return net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
|
||||
}
|
||||
|
||||
func env(key, def string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envInt(key string, def int) int {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envBool(key string, def bool) bool {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"git.saqut.com/saqut/mwse/internal/ws"
|
||||
)
|
||||
|
||||
// apiKeyStore holds the issued server-to-server API keys. The original kept these
|
||||
// in a process-local Map; this matches that (keys do not survive a restart).
|
||||
type apiKeyStore struct {
|
||||
mu sync.RWMutex
|
||||
keys map[string]string // key -> domain
|
||||
}
|
||||
|
||||
func newAPIKeyStore() *apiKeyStore {
|
||||
return &apiKeyStore{keys: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (s *apiKeyStore) issue(domain string) string {
|
||||
key := newToken()
|
||||
s.mu.Lock()
|
||||
s.keys[key] = domain
|
||||
s.mu.Unlock()
|
||||
return key
|
||||
}
|
||||
|
||||
func (s *apiKeyStore) domain(key string) (string, bool) {
|
||||
s.mu.RLock()
|
||||
d, ok := s.keys[key]
|
||||
s.mu.RUnlock()
|
||||
return d, ok
|
||||
}
|
||||
|
||||
// auth wraps a handler with x-api-key validation, passing the caller's domain on
|
||||
// the request context-free closure argument.
|
||||
func (s *apiKeyStore) auth(next func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.Header.Get("x-api-key")
|
||||
if key == "" {
|
||||
writeJSON(w, http.StatusUnauthorized, fail("API_KEY_REQUIRED"))
|
||||
return
|
||||
}
|
||||
domain, ok := s.domain(key)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, fail("INVALID_API_KEY"))
|
||||
return
|
||||
}
|
||||
next(w, r, domain)
|
||||
}
|
||||
}
|
||||
|
||||
// registerAPI mounts the /api control-plane routes onto mux. It ports the read
|
||||
// endpoints and the core server-initiated messaging endpoints from api.js. The
|
||||
// server-as-room-participant (join/leave) and webhook endpoints are intentionally
|
||||
// deferred to feature-parity work (see REVIEW.md).
|
||||
func registerAPI(mux *http.ServeMux, hub *ws.Hub) {
|
||||
keys := newAPIKeyStore()
|
||||
|
||||
mux.HandleFunc("POST /api/auth/key", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Domain string `json:"domain"`
|
||||
}
|
||||
if !decode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if body.Domain == "" {
|
||||
writeJSON(w, http.StatusOK, fail("DOMAIN_REQUIRED"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "success", "key": keys.issue(body.Domain)})
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /api/rooms", func(w http.ResponseWriter, r *http.Request) {
|
||||
rooms := make([]map[string]any, 0)
|
||||
for _, room := range hub.Rooms() {
|
||||
rooms = append(rooms, map[string]any{
|
||||
"id": room.ID,
|
||||
"name": room.Name,
|
||||
"accessType": room.AccessType,
|
||||
"joinType": room.JoinType,
|
||||
"description": room.Description,
|
||||
"clientCount": room.Size(),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "success", "rooms": rooms})
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /api/clients", func(w http.ResponseWriter, r *http.Request) {
|
||||
clients := make([]map[string]any, 0)
|
||||
for _, c := range hub.Clients() {
|
||||
clients = append(clients, map[string]any{
|
||||
"id": c.ID,
|
||||
"rooms": c.Rooms(),
|
||||
"pairs": c.Pairs(),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "success", "clients": clients})
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /api/room/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
room, ok := hub.Room(r.PathValue("id"))
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusOK, fail("ROOM_NOT_FOUND"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "success", "room": room.ToJSON(false)})
|
||||
})
|
||||
|
||||
mux.HandleFunc("POST /api/client/{id}/send", keys.auth(func(w http.ResponseWriter, r *http.Request, domain string) {
|
||||
var body struct {
|
||||
Pack any `json:"pack"`
|
||||
}
|
||||
if !decode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
client, ok := hub.Client(r.PathValue("id"))
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusOK, fail("CLIENT_NOT_FOUND"))
|
||||
return
|
||||
}
|
||||
if body.Pack == nil {
|
||||
writeJSON(w, http.StatusOK, fail("PACK_REQUIRED"))
|
||||
return
|
||||
}
|
||||
client.Signal("server/pack", map[string]any{"from": "server", "fromServer": domain, "pack": body.Pack})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "success"})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /api/room/{id}/send", keys.auth(func(w http.ResponseWriter, r *http.Request, domain string) {
|
||||
var body struct {
|
||||
Pack any `json:"pack"`
|
||||
Wom bool `json:"wom"`
|
||||
}
|
||||
if !decode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
room, ok := hub.Room(id)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusOK, fail("ROOM_NOT_FOUND"))
|
||||
return
|
||||
}
|
||||
if body.Pack == nil {
|
||||
writeJSON(w, http.StatusOK, fail("PACK_REQUIRED"))
|
||||
return
|
||||
}
|
||||
room.Broadcast(
|
||||
"server/pack/room",
|
||||
map[string]any{"from": "server", "fromServer": domain, "pack": body.Pack, "roomId": id},
|
||||
"",
|
||||
nil,
|
||||
)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "success"})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /api/room/create", keys.auth(func(w http.ResponseWriter, r *http.Request, domain string) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
AccessType string `json:"accessType"`
|
||||
JoinType string `json:"joinType"`
|
||||
Description string `json:"description"`
|
||||
Credential string `json:"credential"`
|
||||
}
|
||||
if !decode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if body.Name == "" {
|
||||
writeJSON(w, http.StatusOK, fail("NAME_REQUIRED"))
|
||||
return
|
||||
}
|
||||
if _, exists := hub.RoomByName(body.Name); exists {
|
||||
writeJSON(w, http.StatusOK, fail("ROOM_ALREADY_EXISTS"))
|
||||
return
|
||||
}
|
||||
room := ws.NewRoom(hub)
|
||||
room.Name = body.Name
|
||||
room.AccessType = orDefault(body.AccessType, "public")
|
||||
room.JoinType = orDefault(body.JoinType, "free")
|
||||
room.Description = body.Description
|
||||
room.OwnerID = "server"
|
||||
if body.Credential != "" {
|
||||
room.Credential = sha256hex(body.Credential)
|
||||
}
|
||||
room.Publish()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "success", "room": room.ToJSON(false)})
|
||||
}))
|
||||
}
|
||||
|
||||
func orDefault(v, def string) string {
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func fail(message string) map[string]any {
|
||||
return map[string]any{"status": "fail", "message": message}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
// decode reads a JSON request body, writing a fail response and returning false
|
||||
// when the body is malformed.
|
||||
func decode(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
if r.Body == nil {
|
||||
return true
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(dst); err != nil && err.Error() != "EOF" {
|
||||
writeJSON(w, http.StatusBadRequest, fail("INVALID_JSON"))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
// Package httpserver assembles the HTTP surface of the engine: the WebSocket
|
||||
// upgrade endpoint, the static asset routes (the built SDK and the public files),
|
||||
// and the /api control plane. It mirrors the routing of the original
|
||||
// HTTPServer.js while adding timeouts and graceful shutdown (#25).
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"git.saqut.com/saqut/mwse/internal/config"
|
||||
"git.saqut.com/saqut/mwse/internal/ws"
|
||||
)
|
||||
|
||||
// New builds the *http.Server. WebSocket upgrades are detected on ANY path and
|
||||
// routed to the engine (the SDK derives its endpoint from wherever the script was
|
||||
// served, so the upgrade may arrive at "/" or "/script/"). All other requests go
|
||||
// through the static/API mux.
|
||||
func New(hub *ws.Hub, cfg config.Config) *http.Server {
|
||||
wsServer := ws.NewServer(hub)
|
||||
mux := http.NewServeMux()
|
||||
|
||||
registerAPI(mux, hub)
|
||||
registerStatic(mux, cfg)
|
||||
|
||||
root := func(w http.ResponseWriter, r *http.Request) {
|
||||
if websocket.IsWebSocketUpgrade(r) {
|
||||
wsServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
return &http.Server{
|
||||
Addr: cfg.Addr(),
|
||||
Handler: http.HandlerFunc(root),
|
||||
ReadHeaderTimeout: cfg.ReadHeaderTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
// registerStatic wires the asset routes:
|
||||
//
|
||||
// - /script -> the built SDK entry (script/index.js)
|
||||
// - /script/<file> -> files under the script directory
|
||||
// - / -> the SDK entry (so a bare visit returns the script)
|
||||
// - /<file> -> a matching file under the public directory
|
||||
// - anything else -> the status document (status.xml)
|
||||
func registerStatic(mux *http.ServeMux, cfg config.Config) {
|
||||
scriptIndex := filepath.Join(cfg.ScriptDir, "index.js")
|
||||
statusDoc := filepath.Join(cfg.ScriptDir, "status.xml")
|
||||
|
||||
mux.HandleFunc("/script", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, scriptIndex)
|
||||
})
|
||||
mux.Handle("/script/", http.StripPrefix("/script/", http.FileServer(http.Dir(cfg.ScriptDir))))
|
||||
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
http.ServeFile(w, r, scriptIndex)
|
||||
return
|
||||
}
|
||||
if f, ok := safePublicFile(cfg.PublicDir, r.URL.Path); ok {
|
||||
http.ServeFile(w, r, f)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, statusDoc)
|
||||
})
|
||||
}
|
||||
|
||||
// safePublicFile resolves urlPath to a regular file under publicDir, guarding
|
||||
// against path traversal. It returns ok=false when no such file exists.
|
||||
func safePublicFile(publicDir, urlPath string) (string, bool) {
|
||||
clean := filepath.Clean("/" + strings.TrimPrefix(urlPath, "/"))
|
||||
full := filepath.Join(publicDir, clean)
|
||||
|
||||
absPublic, err := filepath.Abs(publicDir)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
absFull, err := filepath.Abs(full)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if absFull != absPublic && !strings.HasPrefix(absFull, absPublic+string(os.PathSeparator)) {
|
||||
return "", false // escaped the public directory
|
||||
}
|
||||
if fi, err := os.Stat(absFull); err == nil && !fi.IsDir() {
|
||||
return absFull, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// newToken returns a random UUIDv4-shaped token for API keys (the original used
|
||||
// crypto.randomUUID()).
|
||||
func newToken() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
panic(fmt.Sprintf("httpserver: cannot read random bytes: %v", err))
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
|
||||
// sha256hex hashes a credential to a hex digest, matching the Room service.
|
||||
func sha256hex(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
// Package protocol implements the WSTS (WebSocket Transport/Signal) wire format
|
||||
// used by MWSE. The format is FROZEN: the TypeScript SDK in ./frontend speaks it
|
||||
// verbatim, so the Go engine must encode and decode it byte-for-byte the same way
|
||||
// the original Node.js server did.
|
||||
//
|
||||
// # Wire format
|
||||
//
|
||||
// Every WebSocket text frame carries a JSON array. The shape of that array is:
|
||||
//
|
||||
// [ message, id?, action? ]
|
||||
//
|
||||
// - message : an object, always present. It carries a "type" field that selects
|
||||
// a handler, plus handler-specific fields.
|
||||
// - id : optional. A number identifies a client-initiated request/stream
|
||||
// whose response must be correlated. A string in this slot (e.g. "R") is used
|
||||
// by the SDK's "fire and forget" path and produces no response.
|
||||
// - action : optional. "R" = request (reply once, flagged "E" = end),
|
||||
// "S" = stream (reply, flagged "C" = continue).
|
||||
//
|
||||
// When the server initiates a message (a "signal" such as room/joined) it sends:
|
||||
//
|
||||
// [ payload, signalName ]
|
||||
//
|
||||
// i.e. the signal name lives in the id slot as a string, which the SDK routes to
|
||||
// its signal listeners.
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Flags carried in the action slot of a server reply.
|
||||
const (
|
||||
FlagEnd = "E" // terminates a request: [resp, id, "E"]
|
||||
FlagContinue = "C" // a stream chunk: [resp, id, "C"]
|
||||
|
||||
actionRequest = "R" // client asked for a single response
|
||||
actionStream = "S" // client opened a stream
|
||||
)
|
||||
|
||||
// ErrEmptyFrame is returned when a frame decodes to an empty array.
|
||||
var ErrEmptyFrame = errors.New("protocol: empty frame")
|
||||
|
||||
// Message is a decoded inbound message object. Values follow Go's encoding/json
|
||||
// conventions (numbers are float64, objects are map[string]any, etc.). The helper
|
||||
// accessors below keep handler code readable and tolerant of missing fields.
|
||||
type Message map[string]any
|
||||
|
||||
// Type returns the handler selector ("type" field), or "" when absent.
|
||||
func (m Message) Type() string { return m.Str("type") }
|
||||
|
||||
// Str returns a string field, or "" if missing or not a string.
|
||||
func (m Message) Str(key string) string {
|
||||
if s, ok := m[key].(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Int returns a numeric field as an int, or 0 if missing or not a number.
|
||||
func (m Message) Int(key string) int {
|
||||
if f, ok := m[key].(float64); ok {
|
||||
return int(f)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Bool returns a strict boolean field, or false if missing or not a bool.
|
||||
func (m Message) Bool(key string) bool {
|
||||
b, _ := m[key].(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
// Truthy mirrors JavaScript's "!!value" coercion. The SDK frequently sends
|
||||
// numeric flags (value: 1 / value: 0), so handlers that toggle state use this.
|
||||
func (m Message) Truthy(key string) bool {
|
||||
return jsTruthy(m[key])
|
||||
}
|
||||
|
||||
// Get returns the raw value for a key (may be nil).
|
||||
func (m Message) Get(key string) any { return m[key] }
|
||||
|
||||
// Has reports whether the key is present.
|
||||
func (m Message) Has(key string) bool {
|
||||
_, ok := m[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func jsTruthy(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return false
|
||||
case bool:
|
||||
return t
|
||||
case float64:
|
||||
return t != 0
|
||||
case string:
|
||||
return t != ""
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Envelope is a decoded inbound frame.
|
||||
type Envelope struct {
|
||||
// Message is the message object (arr[0]). May be nil if the frame did not
|
||||
// carry an object there; handlers treat a nil/typeless message as MISSING_TYPE.
|
||||
Message Message
|
||||
// ID is the correlation id (arr[1]) when present and a number or string.
|
||||
ID any
|
||||
// HasID is true when arr[1] is a number or a string. This matches the Node
|
||||
// server's `typeof id === 'number' || typeof id === 'string'` branch, which
|
||||
// decides whether a reply may be sent at all.
|
||||
HasID bool
|
||||
// Action is the action flag (arr[2]): "R", "S", or "".
|
||||
Action string
|
||||
}
|
||||
|
||||
// WantsReply reports whether this envelope should produce a response, and with
|
||||
// which terminating flag. It is false for fire-and-forget and broadcast frames.
|
||||
func (e *Envelope) WantsReply() (flag string, ok bool) {
|
||||
if !e.HasID {
|
||||
return "", false
|
||||
}
|
||||
switch e.Action {
|
||||
case actionRequest:
|
||||
return FlagEnd, true
|
||||
case actionStream:
|
||||
return FlagContinue, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// IsBroadcast reports whether the frame is in the "no id" branch, where the Node
|
||||
// server inspected the handler result for a broadcast directive.
|
||||
func (e *Envelope) IsBroadcast() bool { return !e.HasID }
|
||||
|
||||
// Decode parses a raw text frame into an Envelope. A frame that is not a JSON
|
||||
// array, or is an empty array, is an error (the caller reports it as a message
|
||||
// error, exactly as the Node server emitted 'messageError').
|
||||
func Decode(data []byte) (*Envelope, error) {
|
||||
var arr []json.RawMessage
|
||||
if err := json.Unmarshal(data, &arr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(arr) == 0 {
|
||||
return nil, ErrEmptyFrame
|
||||
}
|
||||
|
||||
env := &Envelope{}
|
||||
|
||||
// arr[0] -> message object. If it is not an object, Message stays nil and the
|
||||
// router will respond MISSING_TYPE, matching the Node destructuring behaviour.
|
||||
var raw any
|
||||
if err := json.Unmarshal(arr[0], &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if obj, ok := raw.(map[string]any); ok {
|
||||
env.Message = Message(obj)
|
||||
}
|
||||
|
||||
// arr[1] -> id. Only numbers and strings count as an id.
|
||||
if len(arr) >= 2 {
|
||||
var id any
|
||||
if err := json.Unmarshal(arr[1], &id); err == nil {
|
||||
switch id.(type) {
|
||||
case float64, string:
|
||||
env.HasID = true
|
||||
env.ID = id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// arr[2] -> action flag.
|
||||
if len(arr) >= 3 {
|
||||
var action string
|
||||
_ = json.Unmarshal(arr[2], &action)
|
||||
env.Action = action
|
||||
}
|
||||
|
||||
return env, nil
|
||||
}
|
||||
|
||||
// Reply builds the wire value for a correlated response: [payload, id, flag].
|
||||
func Reply(payload any, id any, flag string) []any {
|
||||
return []any{payload, id, flag}
|
||||
}
|
||||
|
||||
// Signal builds the wire value for a server-initiated message: [payload, name].
|
||||
func Signal(name string, payload any) []any {
|
||||
return []any{payload, name}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func decodeString(t *testing.T, s string) *Envelope {
|
||||
t.Helper()
|
||||
env, err := Decode([]byte(s))
|
||||
if err != nil {
|
||||
t.Fatalf("Decode(%q) error: %v", s, err)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func TestDecodeRequest(t *testing.T) {
|
||||
// [message, numericId, "R"] -> a request that wants an "E"-terminated reply.
|
||||
env := decodeString(t, `[{"type":"my/socketid"}, 7, "R"]`)
|
||||
if env.Message.Type() != "my/socketid" {
|
||||
t.Fatalf("type = %q", env.Message.Type())
|
||||
}
|
||||
if !env.HasID {
|
||||
t.Fatal("expected HasID")
|
||||
}
|
||||
flag, ok := env.WantsReply()
|
||||
if !ok || flag != FlagEnd {
|
||||
t.Fatalf("WantsReply = (%q,%v), want (E,true)", flag, ok)
|
||||
}
|
||||
if env.IsBroadcast() {
|
||||
t.Fatal("request must not be a broadcast")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeStream(t *testing.T) {
|
||||
env := decodeString(t, `[{"type":"sub"}, 9, "S"]`)
|
||||
flag, ok := env.WantsReply()
|
||||
if !ok || flag != FlagContinue {
|
||||
t.Fatalf("WantsReply = (%q,%v), want (C,true)", flag, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeFireAndForget(t *testing.T) {
|
||||
// The SDK's SendOnly path: [message, "R"]. The "R" sits in the id slot as a
|
||||
// string, so a handler runs but no reply is produced.
|
||||
env := decodeString(t, `[{"type":"connection/packsending","value":0}, "R"]`)
|
||||
if !env.HasID {
|
||||
t.Fatal("string id should set HasID")
|
||||
}
|
||||
if _, ok := env.WantsReply(); ok {
|
||||
t.Fatal("fire-and-forget must not want a reply")
|
||||
}
|
||||
if env.IsBroadcast() {
|
||||
t.Fatal("fire-and-forget is not a broadcast")
|
||||
}
|
||||
if env.Message.Truthy("value") {
|
||||
t.Fatal("value:0 should be falsey")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeBroadcast(t *testing.T) {
|
||||
// A bare [message] frame: no id -> the broadcast branch.
|
||||
env := decodeString(t, `[{"type":"hello"}]`)
|
||||
if env.HasID {
|
||||
t.Fatal("bare frame should not have an id")
|
||||
}
|
||||
if !env.IsBroadcast() {
|
||||
t.Fatal("bare frame should be a broadcast")
|
||||
}
|
||||
if _, ok := env.WantsReply(); ok {
|
||||
t.Fatal("broadcast must not want a reply")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeMissingTypeObject(t *testing.T) {
|
||||
// arr[0] not an object -> Message is nil and Type() is empty.
|
||||
env := decodeString(t, `["not-an-object", 1, "R"]`)
|
||||
if env.Message != nil {
|
||||
t.Fatalf("expected nil Message, got %v", env.Message)
|
||||
}
|
||||
if env.Message.Type() != "" {
|
||||
t.Fatal("nil message type should be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeErrors(t *testing.T) {
|
||||
if _, err := Decode([]byte(`{"type":"x"}`)); err == nil {
|
||||
t.Fatal("object (not array) should error")
|
||||
}
|
||||
if _, err := Decode([]byte(`[]`)); err != ErrEmptyFrame {
|
||||
t.Fatalf("empty array err = %v, want ErrEmptyFrame", err)
|
||||
}
|
||||
if _, err := Decode([]byte(`not json`)); err == nil {
|
||||
t.Fatal("invalid json should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumericIDRoundTripsAsInteger(t *testing.T) {
|
||||
// A numeric id must come back out as an integer, not "7.0", so the SDK's
|
||||
// integer-keyed event pool matches it.
|
||||
env := decodeString(t, `[{"type":"x"}, 7, "R"]`)
|
||||
reply := Reply("ok", env.ID, FlagEnd)
|
||||
b, err := json.Marshal(reply)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := string(b), `["ok",7,"E"]`; got != want {
|
||||
t.Fatalf("reply = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalShape(t *testing.T) {
|
||||
b, err := json.Marshal(Signal("room/joined", map[string]any{"id": "abc"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := string(b), `[{"id":"abc"},"room/joined"]`; got != want {
|
||||
t.Fatalf("signal = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageAccessors(t *testing.T) {
|
||||
m := Message{"type": "t", "to": "peer-1", "n": float64(42), "b": true, "s": "x"}
|
||||
if m.Str("to") != "peer-1" {
|
||||
t.Fatal("Str")
|
||||
}
|
||||
if m.Int("n") != 42 {
|
||||
t.Fatal("Int")
|
||||
}
|
||||
if !m.Bool("b") {
|
||||
t.Fatal("Bool")
|
||||
}
|
||||
if !m.Truthy("s") || m.Truthy("missing") {
|
||||
t.Fatal("Truthy")
|
||||
}
|
||||
if !m.Has("to") || m.Has("nope") {
|
||||
t.Fatal("Has")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"git.saqut.com/saqut/mwse/internal/protocol"
|
||||
"git.saqut.com/saqut/mwse/internal/ws"
|
||||
)
|
||||
|
||||
// ---- relationship helpers ------------------------------------------------
|
||||
//
|
||||
// "Secure" reachability comes in two forms: a mutual pairing, or co-membership of
|
||||
// a room. These helpers centralise the checks the original Client.isSecure /
|
||||
// getSucureClients spread across files (and fix their bugs).
|
||||
|
||||
// isPaired reports a *mutual* pairing between a and b.
|
||||
func isPaired(a, b *ws.Client) bool {
|
||||
return a.HasPair(b.ID) && b.HasPair(a.ID)
|
||||
}
|
||||
|
||||
// shareRoom reports whether a and b are members of at least one common room.
|
||||
func shareRoom(hub *ws.Hub, a, b *ws.Client) bool {
|
||||
for _, rid := range a.Rooms() {
|
||||
if r, ok := hub.Room(rid); ok && r.Has(b.ID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isSecure reports whether a may address the client with id peerID.
|
||||
func isSecure(hub *ws.Hub, a *ws.Client, peerID string) bool {
|
||||
peer, ok := hub.Client(peerID)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return isPaired(a, peer) || shareRoom(hub, a, peer)
|
||||
}
|
||||
|
||||
// secureClients returns the connected peers c may exchange info with: those c has
|
||||
// paired toward, and those sharing a room with c.
|
||||
func secureClients(hub *ws.Hub, c *ws.Client) (pairs, roompairs map[string]*ws.Client) {
|
||||
pairs = make(map[string]*ws.Client)
|
||||
for _, id := range c.Pairs() {
|
||||
if pc, ok := hub.Client(id); ok {
|
||||
pairs[id] = pc
|
||||
}
|
||||
}
|
||||
roompairs = make(map[string]*ws.Client)
|
||||
for _, rid := range c.Rooms() {
|
||||
r, ok := hub.Room(rid)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, m := range r.Members() {
|
||||
if m.ID == c.ID {
|
||||
continue
|
||||
}
|
||||
roompairs[m.ID] = m
|
||||
}
|
||||
}
|
||||
return pairs, roompairs
|
||||
}
|
||||
|
||||
func registerAuth(hub *ws.Hub) {
|
||||
// On disconnect: tell secure peers we are gone, then drop pairing edges both
|
||||
// ways so no stale references remain.
|
||||
hub.OnDisconnect(func(c *ws.Client) {
|
||||
pairs, roompairs := secureClients(hub, c)
|
||||
|
||||
notified := make(map[string]bool)
|
||||
notify := func(set map[string]*ws.Client) {
|
||||
for id, peer := range set {
|
||||
if notified[id] {
|
||||
continue
|
||||
}
|
||||
notified[id] = true
|
||||
peer.Signal("peer/disconnect", map[string]any{"id": c.ID})
|
||||
}
|
||||
}
|
||||
notify(pairs)
|
||||
notify(roompairs)
|
||||
|
||||
for id, peer := range pairs {
|
||||
peer.RemovePair(c.ID)
|
||||
c.RemovePair(id)
|
||||
}
|
||||
})
|
||||
|
||||
hub.Register("auth/pair-system", func(c *ws.Client, m protocol.Message) any {
|
||||
switch m.Str("value") {
|
||||
case "everybody":
|
||||
c.SetRequiredPair(true)
|
||||
return success()
|
||||
case "disable":
|
||||
c.SetRequiredPair(false)
|
||||
return success()
|
||||
}
|
||||
return fail("INVALID_VALUE")
|
||||
})
|
||||
|
||||
hub.Register("my/socketid", func(c *ws.Client, m protocol.Message) any {
|
||||
return c.ID
|
||||
})
|
||||
|
||||
hub.Register("auth/public", func(c *ws.Client, m protocol.Message) any {
|
||||
c.SetRequiredPair(false)
|
||||
return map[string]any{"value": "success", "mode": "public"}
|
||||
})
|
||||
|
||||
hub.Register("auth/private", func(c *ws.Client, m protocol.Message) any {
|
||||
c.SetRequiredPair(true)
|
||||
return map[string]any{"value": "success", "mode": "private"}
|
||||
})
|
||||
|
||||
// request/pair: ask `to` to pair with us. We record our side of the edge and
|
||||
// notify the target; they complete it with accept/pair.
|
||||
hub.Register("request/pair", func(c *ws.Client, m protocol.Message) any {
|
||||
to := m.Str("to")
|
||||
target, ok := hub.Client(to)
|
||||
if !ok {
|
||||
return fail("CLIENT_NOT_FOUND")
|
||||
}
|
||||
if isPaired(c, target) {
|
||||
return map[string]any{"status": "success", "message": "ALREADY-PAIRED"}
|
||||
}
|
||||
if c.HasPair(to) {
|
||||
return fail("ALREADY-REQUESTED")
|
||||
}
|
||||
c.AddPair(to)
|
||||
target.Signal("request/pair", map[string]any{"from": c.ID, "info": c.Info()})
|
||||
return map[string]any{"status": "success", "message": "REQUESTED"}
|
||||
})
|
||||
|
||||
// accept/pair: complete a pairing the peer `to` requested from us.
|
||||
hub.Register("accept/pair", func(c *ws.Client, m protocol.Message) any {
|
||||
to := m.Str("to")
|
||||
requester, ok := hub.Client(to)
|
||||
if !ok {
|
||||
return fail("CLIENT_NOT_FOUND")
|
||||
}
|
||||
if isPaired(c, requester) {
|
||||
return map[string]any{"status": "success", "message": "ALREADY-PAIRED"}
|
||||
}
|
||||
if !requester.HasPair(c.ID) {
|
||||
return fail("NOT_REQUESTED_PAIR")
|
||||
}
|
||||
c.AddPair(to)
|
||||
requester.Signal("accepted/pair", map[string]any{"from": c.ID, "info": c.Info()})
|
||||
return success()
|
||||
})
|
||||
|
||||
// reject/pair and end/pair both tear the edge down in both directions and
|
||||
// notify the other side.
|
||||
teardown := func(c *ws.Client, m protocol.Message) any {
|
||||
to := m.Str("to")
|
||||
other, ok := hub.Client(to)
|
||||
if !ok {
|
||||
return fail("CLIENT_NOT_FOUND")
|
||||
}
|
||||
c.RemovePair(to)
|
||||
other.RemovePair(c.ID)
|
||||
other.Signal("end/pair", map[string]any{"from": c.ID})
|
||||
return success()
|
||||
}
|
||||
hub.Register("reject/pair", teardown)
|
||||
hub.Register("end/pair", teardown)
|
||||
|
||||
hub.Register("pair/list", func(c *ws.Client, m protocol.Message) any {
|
||||
var list []string
|
||||
for _, id := range c.Pairs() {
|
||||
if other, ok := hub.Client(id); ok && other.HasPair(c.ID) {
|
||||
list = append(list, id)
|
||||
}
|
||||
}
|
||||
return map[string]any{"type": "pair/list", "value": list}
|
||||
})
|
||||
|
||||
hub.Register("is/reachable", func(c *ws.Client, m protocol.Message) any {
|
||||
to := m.Str("to")
|
||||
other, ok := hub.Client(to)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if other.RequiredPair() && !other.HasPair(c.ID) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// auth/info: set our metadata and push the change to every secure peer.
|
||||
hub.Register("auth/info", func(c *ws.Client, m protocol.Message) any {
|
||||
name := m.Str("name")
|
||||
value := m.Get("value")
|
||||
c.SetInfo(name, value)
|
||||
|
||||
pairs, roompairs := secureClients(hub, c)
|
||||
payload := map[string]any{"from": c.ID, "name": name, "value": value}
|
||||
for _, peer := range pairs {
|
||||
peer.Signal("pair/info", payload)
|
||||
}
|
||||
for _, peer := range roompairs {
|
||||
peer.Signal("pair/info", payload)
|
||||
}
|
||||
return success()
|
||||
})
|
||||
|
||||
hub.Register("peer/info", func(c *ws.Client, m protocol.Message) any {
|
||||
peerID := m.Str("peer")
|
||||
if !isSecure(hub, c, peerID) {
|
||||
return map[string]any{"status": "fail", "message": "unaccessible user"}
|
||||
}
|
||||
peer, _ := hub.Client(peerID)
|
||||
return map[string]any{"status": "success", "info": peer.Info()}
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"git.saqut.com/saqut/mwse/internal/protocol"
|
||||
"git.saqut.com/saqut/mwse/internal/ws"
|
||||
)
|
||||
|
||||
// handshakeResult returns the optional acknowledgement for a relay handler. When
|
||||
// the caller did not ask for a handshake the relay is fire-and-forget (nil), so
|
||||
// the generic dispatcher sends no reply.
|
||||
func handshakeResult(m protocol.Message, success bool) any {
|
||||
if !m.Truthy("handshake") {
|
||||
return nil
|
||||
}
|
||||
if success {
|
||||
return map[string]any{"type": "success"}
|
||||
}
|
||||
return map[string]any{"type": "fail"}
|
||||
}
|
||||
|
||||
func registerDataTransfer(hub *ws.Hub) {
|
||||
// pack/to: relay a data pack to another peer, honouring both peers' relay
|
||||
// flags and the target's pairing policy. When the target does not require
|
||||
// pairing, an implicit pairing is established so subsequent traffic flows.
|
||||
hub.Register("pack/to", func(c *ws.Client, m protocol.Message) any {
|
||||
if !c.PackReadable() {
|
||||
return handshakeResult(m, false)
|
||||
}
|
||||
to := m.Str("to")
|
||||
other, ok := hub.Client(to)
|
||||
if !ok {
|
||||
return handshakeResult(m, false)
|
||||
}
|
||||
if other.RequiredPair() {
|
||||
if !other.HasPair(c.ID) {
|
||||
return handshakeResult(m, false)
|
||||
}
|
||||
} else if !other.HasPair(c.ID) {
|
||||
other.AddPair(c.ID)
|
||||
c.AddPair(other.ID)
|
||||
}
|
||||
if !other.PackWriteable() {
|
||||
return handshakeResult(m, false)
|
||||
}
|
||||
other.Signal("pack", map[string]any{"from": c.ID, "pack": m.Get("pack")})
|
||||
return handshakeResult(m, true)
|
||||
})
|
||||
|
||||
// request/to: relay a request to a peer. The reply travels back later via
|
||||
// response/to, carrying the original request id, so this handler itself does
|
||||
// not produce the answer.
|
||||
hub.Register("request/to", func(c *ws.Client, m protocol.Message) any {
|
||||
other, ok := hub.Client(m.Str("to"))
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if other.RequiredPair() {
|
||||
if !other.HasPair(c.ID) {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
other.AddPair(c.ID)
|
||||
c.AddPair(other.ID)
|
||||
}
|
||||
other.Signal("request", map[string]any{"from": c.ID, "pack": m.Get("pack")})
|
||||
return nil
|
||||
})
|
||||
|
||||
// response/to: deliver a peer's response back to the original requester. The
|
||||
// frame uses the numeric request id in the signal slot so the requester's
|
||||
// event pool resolves the pending promise.
|
||||
hub.Register("response/to", func(c *ws.Client, m protocol.Message) any {
|
||||
other, ok := hub.Client(m.Str("to"))
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if other.RequiredPair() && !other.HasPair(c.ID) {
|
||||
return nil
|
||||
}
|
||||
other.Send([]any{map[string]any{"from": c.ID, "pack": m.Get("pack")}, m.Get("id")})
|
||||
return nil
|
||||
})
|
||||
|
||||
// pack/room: relay a data pack to every writable member of a room the sender
|
||||
// belongs to. "wom" (without me) excludes the sender.
|
||||
hub.Register("pack/room", func(c *ws.Client, m protocol.Message) any {
|
||||
if !c.PackReadable() {
|
||||
return handshakeResult(m, false)
|
||||
}
|
||||
to := m.Str("to")
|
||||
room, ok := hub.Room(to)
|
||||
if !ok {
|
||||
return handshakeResult(m, false)
|
||||
}
|
||||
if !c.InRoom(to) {
|
||||
return handshakeResult(m, false)
|
||||
}
|
||||
except := ""
|
||||
if m.Truthy("wom") {
|
||||
except = c.ID
|
||||
}
|
||||
room.Broadcast(
|
||||
"pack/room",
|
||||
map[string]any{"from": to, "pack": m.Get("pack"), "sender": c.ID},
|
||||
except,
|
||||
(*ws.Client).PackWriteable,
|
||||
)
|
||||
return handshakeResult(m, true)
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"git.saqut.com/saqut/mwse/internal/protocol"
|
||||
"git.saqut.com/saqut/mwse/internal/ws"
|
||||
)
|
||||
|
||||
// shortCodeAlphabet is the 22-letter set the original used (note: J, Q, U, W are
|
||||
// intentionally absent). Three letters give 22^3 = 10,648 codes.
|
||||
const shortCodeAlphabet = "ABCDEFGHIKLMNOPRSTVXYZ"
|
||||
|
||||
// Announcer receives address allocation events. In the multi-process Node
|
||||
// deployment these were forwarded to a parent via process.send for the live
|
||||
// traffic panel. For the single-node 0.1.0 core the default is a no-op; a cluster
|
||||
// integration can supply a real implementation later.
|
||||
type Announcer interface {
|
||||
Announce(kind, action, clientID string, value any)
|
||||
}
|
||||
|
||||
type noopAnnouncer struct{}
|
||||
|
||||
func (noopAnnouncer) Announce(string, string, string, any) {}
|
||||
|
||||
// IPPressure allocates three kinds of unique virtual address to clients. A single
|
||||
// mutex guards all three tables; allocation is infrequent relative to messaging,
|
||||
// so finer-grained locking would add complexity for no real gain.
|
||||
type IPPressure struct {
|
||||
ann Announcer
|
||||
|
||||
mu sync.Mutex
|
||||
busyNumber map[int]string // number -> clientID
|
||||
busyCode map[string]string // shortcode -> clientID
|
||||
busyIP map[string]string // ip -> clientID
|
||||
}
|
||||
|
||||
// NewIPPressure builds an allocator. A nil announcer becomes a no-op.
|
||||
func NewIPPressure(ann Announcer) *IPPressure {
|
||||
if ann == nil {
|
||||
ann = noopAnnouncer{}
|
||||
}
|
||||
return &IPPressure{
|
||||
ann: ann,
|
||||
busyNumber: make(map[int]string),
|
||||
busyCode: make(map[string]string),
|
||||
busyIP: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- number (starts at 24, counts up) -----------------------------------
|
||||
|
||||
func (p *IPPressure) lockNumber(clientID string) int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for n := 24; ; n++ {
|
||||
if _, busy := p.busyNumber[n]; !busy {
|
||||
p.busyNumber[n] = clientID
|
||||
p.ann.Announce("AP_NUMBER", "LOCK", clientID, n)
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *IPPressure) releaseNumber(n int) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if clientID, ok := p.busyNumber[n]; ok {
|
||||
p.ann.Announce("AP_NUMBER", "RELEASE", clientID, n)
|
||||
delete(p.busyNumber, n)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *IPPressure) whoisNumber(n int) (string, bool) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
id, ok := p.busyNumber[n]
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// ---- short code (three letters from the restricted alphabet) ------------
|
||||
|
||||
func (p *IPPressure) lockCode(clientID string) string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for _, a := range shortCodeAlphabet {
|
||||
for _, b := range shortCodeAlphabet {
|
||||
for _, d := range shortCodeAlphabet {
|
||||
code := string([]rune{a, b, d})
|
||||
if _, busy := p.busyCode[code]; !busy {
|
||||
p.busyCode[code] = clientID
|
||||
p.ann.Announce("AP_SHORTCODE", "LOCK", clientID, code)
|
||||
return code
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "" // address space exhausted
|
||||
}
|
||||
|
||||
func (p *IPPressure) releaseCode(code string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if clientID, ok := p.busyCode[code]; ok {
|
||||
p.ann.Announce("AP_SHORTCODE", "RELEASE", clientID, code)
|
||||
delete(p.busyCode, code)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *IPPressure) whoisCode(code string) (string, bool) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
id, ok := p.busyCode[code]
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// ---- ip address (10.0.0.1 upward) ---------------------------------------
|
||||
|
||||
func (p *IPPressure) lockIP(clientID string) string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
a, b, cc, d := 10, 0, 0, 1
|
||||
for {
|
||||
ip := fmt.Sprintf("%d.%d.%d.%d", a, b, cc, d)
|
||||
if _, busy := p.busyIP[ip]; !busy {
|
||||
p.busyIP[ip] = clientID
|
||||
p.ann.Announce("AP_IPADDRESS", "LOCK", clientID, ip)
|
||||
return ip
|
||||
}
|
||||
switch {
|
||||
case d != 255:
|
||||
d++
|
||||
case cc != 255:
|
||||
d, cc = 0, cc+1
|
||||
case b != 255:
|
||||
d, cc, b = 0, 0, b+1
|
||||
case a != 255:
|
||||
d, cc, b, a = 0, 0, 0, a+1
|
||||
default:
|
||||
return "" // address space exhausted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *IPPressure) releaseIP(ip string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if clientID, ok := p.busyIP[ip]; ok {
|
||||
p.ann.Announce("AP_IPADDRESS", "RELEASE", clientID, ip)
|
||||
delete(p.busyIP, ip)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *IPPressure) whoisIP(ip string) (string, bool) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
id, ok := p.busyIP[ip]
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// registerIPPressure wires the alloc/realloc/release/whois handlers and the
|
||||
// disconnect cleanup. The allocator instance is returned for tests.
|
||||
func registerIPPressure(hub *ws.Hub, ann Announcer) *IPPressure {
|
||||
p := NewIPPressure(ann)
|
||||
|
||||
// --- IP address ---
|
||||
hub.Register("alloc/APIPAddress", func(c *ws.Client, m protocol.Message) any {
|
||||
if ip := c.APIP(); ip != "" {
|
||||
return map[string]any{"status": "success", "ip": ip}
|
||||
}
|
||||
ip := p.lockIP(c.ID)
|
||||
if ip == "" {
|
||||
return map[string]any{"status": "fail"}
|
||||
}
|
||||
c.SetAPIP(ip)
|
||||
return map[string]any{"status": "success", "ip": ip}
|
||||
})
|
||||
hub.Register("realloc/APIPAddress", func(c *ws.Client, m protocol.Message) any {
|
||||
if c.APIP() == "" {
|
||||
return map[string]any{"status": "fail"}
|
||||
}
|
||||
p.releaseIP(c.APIP())
|
||||
ip := p.lockIP(c.ID)
|
||||
c.SetAPIP(ip)
|
||||
return map[string]any{"status": "success", "ip": ip}
|
||||
})
|
||||
hub.Register("release/APIPAddress", func(c *ws.Client, m protocol.Message) any {
|
||||
p.releaseIP(c.APIP())
|
||||
c.SetAPIP("")
|
||||
return success()
|
||||
})
|
||||
hub.Register("whois/APIPAddress", func(c *ws.Client, m protocol.Message) any {
|
||||
if id, ok := p.whoisIP(m.Str("whois")); ok {
|
||||
return map[string]any{"status": "success", "socket": id}
|
||||
}
|
||||
return map[string]any{"status": "fail"}
|
||||
})
|
||||
|
||||
// --- number ---
|
||||
hub.Register("alloc/APNumber", func(c *ws.Client, m protocol.Message) any {
|
||||
if n := c.APNumber(); n != 0 {
|
||||
return map[string]any{"status": "success", "number": n}
|
||||
}
|
||||
n := p.lockNumber(c.ID)
|
||||
c.SetAPNumber(n)
|
||||
return map[string]any{"status": "success", "number": n}
|
||||
})
|
||||
hub.Register("realloc/APNumber", func(c *ws.Client, m protocol.Message) any {
|
||||
if c.APNumber() == 0 {
|
||||
return map[string]any{"status": "fail"}
|
||||
}
|
||||
p.releaseNumber(c.APNumber())
|
||||
n := p.lockNumber(c.ID)
|
||||
c.SetAPNumber(n)
|
||||
return map[string]any{"status": "success", "number": n}
|
||||
})
|
||||
hub.Register("release/APNumber", func(c *ws.Client, m protocol.Message) any {
|
||||
p.releaseNumber(c.APNumber())
|
||||
c.SetAPNumber(0)
|
||||
return success()
|
||||
})
|
||||
hub.Register("whois/APNumber", func(c *ws.Client, m protocol.Message) any {
|
||||
if id, ok := p.whoisNumber(m.Int("whois")); ok {
|
||||
return map[string]any{"status": "success", "socket": id}
|
||||
}
|
||||
return map[string]any{"status": "fail"}
|
||||
})
|
||||
|
||||
// --- short code ---
|
||||
hub.Register("alloc/APShortCode", func(c *ws.Client, m protocol.Message) any {
|
||||
if code := c.APShortCode(); code != "" {
|
||||
return map[string]any{"status": "success", "code": code}
|
||||
}
|
||||
code := p.lockCode(c.ID)
|
||||
if code == "" {
|
||||
return map[string]any{"status": "fail"}
|
||||
}
|
||||
c.SetAPShortCode(code)
|
||||
return map[string]any{"status": "success", "code": code}
|
||||
})
|
||||
hub.Register("realloc/APShortCode", func(c *ws.Client, m protocol.Message) any {
|
||||
if c.APShortCode() == "" {
|
||||
return map[string]any{"status": "fail"}
|
||||
}
|
||||
p.releaseCode(c.APShortCode())
|
||||
code := p.lockCode(c.ID)
|
||||
c.SetAPShortCode(code)
|
||||
return map[string]any{"status": "success", "code": code}
|
||||
})
|
||||
hub.Register("release/APShortCode", func(c *ws.Client, m protocol.Message) any {
|
||||
p.releaseCode(c.APShortCode())
|
||||
c.SetAPShortCode("")
|
||||
return success()
|
||||
})
|
||||
hub.Register("whois/APShortCode", func(c *ws.Client, m protocol.Message) any {
|
||||
if id, ok := p.whoisCode(m.Str("whois")); ok {
|
||||
return map[string]any{"status": "success", "socket": id}
|
||||
}
|
||||
return map[string]any{"status": "fail"}
|
||||
})
|
||||
|
||||
// Release every address a client held when it disconnects.
|
||||
hub.OnDisconnect(func(c *ws.Client) {
|
||||
if c.APIP() != "" {
|
||||
p.releaseIP(c.APIP())
|
||||
}
|
||||
if c.APNumber() != 0 {
|
||||
p.releaseNumber(c.APNumber())
|
||||
}
|
||||
if c.APShortCode() != "" {
|
||||
p.releaseCode(c.APShortCode())
|
||||
}
|
||||
})
|
||||
|
||||
return p
|
||||
}
|
||||
|
|
@ -0,0 +1,281 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"git.saqut.com/saqut/mwse/internal/protocol"
|
||||
"git.saqut.com/saqut/mwse/internal/ws"
|
||||
)
|
||||
|
||||
func registerRoom(hub *ws.Hub) {
|
||||
// Every client gets a private room named after its own id on connect, and is
|
||||
// ejected from all rooms on disconnect.
|
||||
hub.OnConnect(func(c *ws.Client) {
|
||||
room := ws.NewRoom(hub)
|
||||
room.ID = c.ID
|
||||
room.AccessType = "private"
|
||||
room.JoinType = "notify"
|
||||
room.Description = "Private room"
|
||||
room.Name = "Your Room | " + c.ID
|
||||
room.OwnerID = c.ID
|
||||
room.Publish()
|
||||
room.Join(c)
|
||||
})
|
||||
|
||||
hub.OnDisconnect(func(c *ws.Client) {
|
||||
if room, ok := hub.Room(c.ID); ok {
|
||||
room.Eject(c)
|
||||
}
|
||||
for _, rid := range c.Rooms() {
|
||||
if r, ok := hub.Room(rid); ok {
|
||||
r.Eject(c)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
hub.Register("myroom-info", func(c *ws.Client, m protocol.Message) any {
|
||||
room, ok := hub.Room(c.ID)
|
||||
if !ok {
|
||||
return fail("NOT-FOUND-ROOM")
|
||||
}
|
||||
return map[string]any{"status": "success", "room": room.ToJSON(false)}
|
||||
})
|
||||
|
||||
hub.Register("room-peers", func(c *ws.Client, m protocol.Message) any {
|
||||
room, ok := hub.Room(m.Str("roomId"))
|
||||
if !ok {
|
||||
return map[string]any{"status": "fail"}
|
||||
}
|
||||
return map[string]any{"status": "success", "peers": ids(room.FilterPeers(toMap(m.Get("filter"))))}
|
||||
})
|
||||
|
||||
hub.Register("room/peer-count", func(c *ws.Client, m protocol.Message) any {
|
||||
room, ok := hub.Room(m.Str("roomId"))
|
||||
if !ok {
|
||||
return map[string]any{"status": "fail"}
|
||||
}
|
||||
return map[string]any{"status": "success", "count": len(room.FilterPeers(toMap(m.Get("filter"))))}
|
||||
})
|
||||
|
||||
hub.Register("room-info", func(c *ws.Client, m protocol.Message) any {
|
||||
if room, ok := hub.RoomByName(m.Str("name")); ok {
|
||||
return map[string]any{"status": "success", "room": room.ToJSON(false)}
|
||||
}
|
||||
return fail("NOT-FOUND-ROOM")
|
||||
})
|
||||
|
||||
hub.Register("joinedrooms", func(c *ws.Client, m protocol.Message) any {
|
||||
var rooms []map[string]any
|
||||
for _, rid := range c.Rooms() {
|
||||
if r, ok := hub.Room(rid); ok {
|
||||
rooms = append(rooms, r.ToJSON(false))
|
||||
}
|
||||
}
|
||||
return rooms
|
||||
})
|
||||
|
||||
hub.Register("closeroom", func(c *ws.Client, m protocol.Message) any {
|
||||
room, ok := hub.Room(m.Str("roomId"))
|
||||
if !ok {
|
||||
return map[string]any{"status": "fail"}
|
||||
}
|
||||
if room.OwnerID == c.ID {
|
||||
room.Down()
|
||||
return success()
|
||||
}
|
||||
return map[string]any{"status": "fail"}
|
||||
})
|
||||
|
||||
hub.Register("create-room", func(c *ws.Client, m protocol.Message) any {
|
||||
if msg := validateCreateRoom(m); msg != "" {
|
||||
return map[string]any{"status": "fail", "messages": msg}
|
||||
}
|
||||
name := m.Str("name")
|
||||
if _, exists := hub.RoomByName(name); exists {
|
||||
return fail("ALREADY-EXISTS")
|
||||
}
|
||||
|
||||
room := ws.NewRoom(hub)
|
||||
room.AccessType = m.Str("accessType")
|
||||
room.NotifyActionInvite = m.Truthy("notifyActionInvite")
|
||||
room.NotifyActionJoined = m.Truthy("notifyActionJoined")
|
||||
room.NotifyActionEjected = m.Truthy("notifyActionEjected")
|
||||
room.JoinType = m.Str("joinType")
|
||||
room.Description = m.Str("description")
|
||||
room.Name = name
|
||||
room.OwnerID = c.ID
|
||||
if cred := m.Str("credential"); cred != "" {
|
||||
room.Credential = sha256hex(cred)
|
||||
}
|
||||
room.Publish()
|
||||
room.Join(c)
|
||||
return map[string]any{"status": "success", "room": room.ToJSON(false)}
|
||||
})
|
||||
|
||||
hub.Register("joinroom", func(c *ws.Client, m protocol.Message) any {
|
||||
room, ok := hub.RoomByName(m.Str("name"))
|
||||
if !ok {
|
||||
return fail("NOT-FOUND-ROOM")
|
||||
}
|
||||
|
||||
fetchInfo := func(resp map[string]any) map[string]any {
|
||||
if m.Truthy("autoFetchInfo") {
|
||||
resp["info"] = room.Info()
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
switch room.JoinType {
|
||||
case "lock":
|
||||
return fail("LOCKED-ROOM")
|
||||
case "password":
|
||||
if room.Credential == sha256hex(m.Str("credential")) {
|
||||
room.Join(c)
|
||||
return fetchInfo(map[string]any{"status": "success", "room": room.ToJSON(false)})
|
||||
}
|
||||
return map[string]any{"status": "fail", "message": "WRONG-PASSWORD", "area": "credential"}
|
||||
case "free":
|
||||
room.Join(c)
|
||||
return fetchInfo(map[string]any{"status": "success", "room": room.ToJSON(false)})
|
||||
case "invite":
|
||||
room.AddWaiting(c.ID)
|
||||
invite := map[string]any{"id": c.ID}
|
||||
if room.NotifyActionInvite {
|
||||
room.Broadcast("room/invite", invite, "", nil)
|
||||
} else if owner, ok := hub.Client(room.OwnerID); ok {
|
||||
owner.Signal("room/invite", invite)
|
||||
}
|
||||
return map[string]any{"status": "success", "message": "INVITE-REQUESTED"}
|
||||
}
|
||||
return fail("NOT-FOUND-ROOM")
|
||||
})
|
||||
|
||||
hub.Register("ejectroom", func(c *ws.Client, m protocol.Message) any {
|
||||
room, ok := hub.Room(m.Str("roomId"))
|
||||
if !ok {
|
||||
return fail("NOT-FOUND-ROOM")
|
||||
}
|
||||
if !room.Has(c.ID) {
|
||||
return fail("ALREADY-ROOM-OUT")
|
||||
}
|
||||
room.Eject(c)
|
||||
return success()
|
||||
})
|
||||
|
||||
hub.Register("accept/invite-room", inviteDecision(hub, true))
|
||||
hub.Register("reject/invite-room", inviteDecision(hub, false))
|
||||
|
||||
hub.Register("room/list", func(c *ws.Client, m protocol.Message) any {
|
||||
var rooms []map[string]any
|
||||
for _, room := range hub.Rooms() {
|
||||
if room.AccessType == "public" {
|
||||
rooms = append(rooms, map[string]any{
|
||||
"name": room.Name,
|
||||
"joinType": room.JoinType,
|
||||
"description": room.Description,
|
||||
"id": room.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
return map[string]any{"type": "public/rooms", "rooms": rooms}
|
||||
})
|
||||
|
||||
hub.Register("room/info", func(c *ws.Client, m protocol.Message) any {
|
||||
room, ok := hub.Room(m.Str("roomId"))
|
||||
if !ok {
|
||||
return fail("NOT-FOUND-ROOM")
|
||||
}
|
||||
if !c.InRoom(room.ID) {
|
||||
return fail("NO-JOINED-ROOM")
|
||||
}
|
||||
if name := m.Str("name"); name != "" {
|
||||
v, _ := room.InfoValue(name)
|
||||
return map[string]any{"status": "success", "value": v}
|
||||
}
|
||||
return map[string]any{"status": "success", "value": room.Info()}
|
||||
})
|
||||
|
||||
hub.Register("room/setinfo", func(c *ws.Client, m protocol.Message) any {
|
||||
room, ok := hub.Room(m.Str("roomId"))
|
||||
if !ok {
|
||||
return fail("NOT-FOUND-ROOM")
|
||||
}
|
||||
if !c.InRoom(room.ID) {
|
||||
return fail("NO-JOINED-ROOM")
|
||||
}
|
||||
name := m.Str("name")
|
||||
value := m.Get("value")
|
||||
room.SetInfo(name, value)
|
||||
room.Broadcast(
|
||||
"room/info",
|
||||
map[string]any{"name": name, "value": value, "roomId": room.ID},
|
||||
c.ID,
|
||||
(*ws.Client).RoomInfoNotifiable,
|
||||
)
|
||||
return success()
|
||||
})
|
||||
}
|
||||
|
||||
// inviteDecision builds the accept/reject invite handlers. The original code was
|
||||
// non-functional here (it called Array methods on a Set and inverted the joinType
|
||||
// check); this implements the intended flow: only the rooms a member belongs to,
|
||||
// only invite rooms, only ids actually on the waiting list.
|
||||
func inviteDecision(hub *ws.Hub, accept bool) handler {
|
||||
return func(c *ws.Client, m protocol.Message) any {
|
||||
room, ok := hub.Room(m.Str("roomId"))
|
||||
if !ok {
|
||||
return fail("NOT-FOUND-ROOM")
|
||||
}
|
||||
if !c.InRoom(room.ID) {
|
||||
return fail("FORBIDDEN-INVITE-ACTIONS")
|
||||
}
|
||||
if room.JoinType != "invite" {
|
||||
return fail("INVALID-DATA")
|
||||
}
|
||||
clientID := m.Str("clientId")
|
||||
if !room.IsWaiting(clientID) {
|
||||
return fail("NO-WAITING-INVITED")
|
||||
}
|
||||
joinClient, ok := hub.Client(clientID)
|
||||
if !ok {
|
||||
room.RemoveWaiting(clientID)
|
||||
return fail("NO-CLIENT")
|
||||
}
|
||||
room.RemoveWaiting(clientID)
|
||||
|
||||
if accept {
|
||||
room.Join(joinClient)
|
||||
joinClient.Signal("room/invite/status", map[string]any{"status": "accepted"})
|
||||
} else {
|
||||
room.Broadcast("room/invite/status", map[string]any{"id": clientID, "roomId": room.ID}, "", nil)
|
||||
joinClient.Signal("room/invite/status", map[string]any{"status": "rejected"})
|
||||
}
|
||||
return success()
|
||||
}
|
||||
}
|
||||
|
||||
// validateCreateRoom checks the create-room payload against the same constraints
|
||||
// the original joi schema described. It returns an empty string when valid.
|
||||
func validateCreateRoom(m protocol.Message) string {
|
||||
if !m.Has("type") {
|
||||
return "type is required"
|
||||
}
|
||||
switch m.Str("accessType") {
|
||||
case "public", "private":
|
||||
default:
|
||||
return "accessType must be public or private"
|
||||
}
|
||||
switch m.Str("joinType") {
|
||||
case "free", "invite", "password", "lock":
|
||||
default:
|
||||
return "joinType must be one of free, invite, password, lock"
|
||||
}
|
||||
if !m.Has("notifyActionInvite") || !m.Has("notifyActionJoined") || !m.Has("notifyActionEjected") {
|
||||
return "notify flags are required"
|
||||
}
|
||||
if m.Str("description") == "" {
|
||||
return "description is required"
|
||||
}
|
||||
if m.Str("name") == "" {
|
||||
return "name is required"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
// Package services ports the message handlers and lifecycle hooks that lived in
|
||||
// Source/Services/* of the Node.js engine: YourID, Session, Auth, Room,
|
||||
// IPPressure and DataTransfer. Each is registered onto a *ws.Hub, which owns the
|
||||
// router and the connect/disconnect event bus.
|
||||
//
|
||||
// Where the original handlers contained outright bugs (a Set used as if it were an
|
||||
// Array, comparing a Client object to a string id, validating against an
|
||||
// undefined schema variable, ...) this port implements the *intended* behaviour
|
||||
// and records the deviation in decisions.md. The on-the-wire message shapes the
|
||||
// SDK depends on are preserved.
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
|
||||
"git.saqut.com/saqut/mwse/internal/protocol"
|
||||
"git.saqut.com/saqut/mwse/internal/ws"
|
||||
)
|
||||
|
||||
// Register wires every service onto the hub. Call once during startup, before the
|
||||
// server begins accepting connections. The order mirrors the Node require() order
|
||||
// so connect-time side effects (id message, private room, session defaults)
|
||||
// happen in the same sequence.
|
||||
func Register(hub *ws.Hub) {
|
||||
registerYourID(hub)
|
||||
registerAuth(hub)
|
||||
registerRoom(hub)
|
||||
registerDataTransfer(hub)
|
||||
registerIPPressure(hub, nil)
|
||||
registerSession(hub)
|
||||
}
|
||||
|
||||
// ---- small response helpers ---------------------------------------------
|
||||
|
||||
// success is the ubiquitous {status:"success"} reply.
|
||||
func success() map[string]any { return map[string]any{"status": "success"} }
|
||||
|
||||
// fail is the ubiquitous {status:"fail", message:...} reply.
|
||||
func fail(message string) map[string]any {
|
||||
return map[string]any{"status": "fail", "message": message}
|
||||
}
|
||||
|
||||
// toMap coerces an arbitrary decoded JSON value to an object, returning an empty
|
||||
// map when it is not one (e.g. a missing "filter" field).
|
||||
func toMap(v any) map[string]any {
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
return m
|
||||
}
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
// sha256hex hashes credentials the same way the original Room service did.
|
||||
func sha256hex(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// ids extracts the client ids from a slice of clients.
|
||||
func ids(clients []*ws.Client) []string {
|
||||
out := make([]string, 0, len(clients))
|
||||
for _, c := range clients {
|
||||
out = append(out, c.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handler is a convenience alias matching ws.Handler's shape, used to keep the
|
||||
// per-service files concise.
|
||||
type handler = func(c *ws.Client, m protocol.Message) any
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.saqut.com/saqut/mwse/internal/protocol"
|
||||
"git.saqut.com/saqut/mwse/internal/testutil"
|
||||
"git.saqut.com/saqut/mwse/internal/ws"
|
||||
)
|
||||
|
||||
// ---- test helpers --------------------------------------------------------
|
||||
|
||||
func newHub() *ws.Hub {
|
||||
hub := ws.NewHub()
|
||||
Register(hub)
|
||||
return hub
|
||||
}
|
||||
|
||||
// connect attaches a client (with services' connect hooks firing) and returns it
|
||||
// along with its captured connection.
|
||||
func connect(hub *ws.Hub, id string) (*ws.Client, *testutil.FakeConn) {
|
||||
fc := testutil.NewFakeConn()
|
||||
c := ws.NewClient(fc, id)
|
||||
hub.Connect(c)
|
||||
return c, fc
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, cond func() bool) {
|
||||
t.Helper()
|
||||
for i := 0; i < 500; i++ {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatal("condition not met within timeout")
|
||||
}
|
||||
|
||||
// findSignal scans the captured frames for a [payload, name] signal.
|
||||
func findSignal(fc *testutil.FakeConn, name string) (map[string]any, bool) {
|
||||
for _, raw := range fc.Writes() {
|
||||
var arr []any
|
||||
if json.Unmarshal(raw, &arr) != nil || len(arr) < 2 {
|
||||
continue
|
||||
}
|
||||
if s, ok := arr[1].(string); ok && s == name {
|
||||
payload, _ := arr[0].(map[string]any)
|
||||
return payload, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func waitSignal(t *testing.T, fc *testutil.FakeConn, name string) map[string]any {
|
||||
t.Helper()
|
||||
waitFor(t, func() bool { _, ok := findSignal(fc, name); return ok })
|
||||
p, _ := findSignal(fc, name)
|
||||
return p
|
||||
}
|
||||
|
||||
func asMap(t *testing.T, v any) map[string]any {
|
||||
t.Helper()
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected map, got %T (%v)", v, v)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// msg builds a protocol.Message from a type and key/value pairs.
|
||||
func msg(typ string, kv ...any) protocol.Message {
|
||||
m := protocol.Message{"type": typ}
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
m[kv[i].(string)] = kv[i+1]
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ---- tests ---------------------------------------------------------------
|
||||
|
||||
func TestConnectAnnouncesIDAndPrivateRoom(t *testing.T) {
|
||||
hub := newHub()
|
||||
c, fc := connect(hub, "alice")
|
||||
|
||||
idPayload := waitSignal(t, fc, "id")
|
||||
if idPayload["value"] != "alice" {
|
||||
t.Fatalf("id signal value = %v, want alice", idPayload["value"])
|
||||
}
|
||||
if _, ok := hub.Room("alice"); !ok {
|
||||
t.Fatal("private room named after the client id should exist")
|
||||
}
|
||||
if !c.InRoom("alice") {
|
||||
t.Fatal("client should be a member of its private room")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairingFlow(t *testing.T) {
|
||||
hub := newHub()
|
||||
a, fa := connect(hub, "a")
|
||||
b, fb := connect(hub, "b")
|
||||
|
||||
// a requests pairing with b.
|
||||
resp := asMap(t, hub.Handle(a, msg("request/pair", "to", "b")))
|
||||
if resp["message"] != "REQUESTED" {
|
||||
t.Fatalf("request/pair = %v", resp)
|
||||
}
|
||||
req := waitSignal(t, fb, "request/pair")
|
||||
if req["from"] != "a" {
|
||||
t.Fatalf("request/pair from = %v, want a", req["from"])
|
||||
}
|
||||
|
||||
// b accepts.
|
||||
resp = asMap(t, hub.Handle(b, msg("accept/pair", "to", "a")))
|
||||
if resp["status"] != "success" {
|
||||
t.Fatalf("accept/pair = %v", resp)
|
||||
}
|
||||
acc := waitSignal(t, fa, "accepted/pair")
|
||||
if acc["from"] != "b" {
|
||||
t.Fatalf("accepted/pair from = %v, want b", acc["from"])
|
||||
}
|
||||
|
||||
if !a.HasPair("b") || !b.HasPair("a") {
|
||||
t.Fatal("both clients should be mutually paired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomCreateJoinAndPackRoom(t *testing.T) {
|
||||
hub := newHub()
|
||||
a, _ := connect(hub, "a")
|
||||
b, fb := connect(hub, "b")
|
||||
|
||||
created := asMap(t, hub.Handle(a, msg("create-room",
|
||||
"accessType", "public",
|
||||
"joinType", "free",
|
||||
"notifyActionInvite", false,
|
||||
"notifyActionJoined", true,
|
||||
"notifyActionEjected", true,
|
||||
"description", "d",
|
||||
"name", "R1",
|
||||
)))
|
||||
if created["status"] != "success" {
|
||||
t.Fatalf("create-room = %v", created)
|
||||
}
|
||||
roomID := asMap(t, created["room"])["id"].(string)
|
||||
|
||||
joined := asMap(t, hub.Handle(b, msg("joinroom", "name", "R1")))
|
||||
if joined["status"] != "success" {
|
||||
t.Fatalf("joinroom = %v", joined)
|
||||
}
|
||||
if !b.InRoom(roomID) {
|
||||
t.Fatal("b should be in the room")
|
||||
}
|
||||
|
||||
// a relays a pack to the room; b should receive it.
|
||||
res := asMap(t, hub.Handle(a, msg("pack/room", "to", roomID, "pack", map[string]any{"x": float64(1)}, "handshake", true)))
|
||||
if res["type"] != "success" {
|
||||
t.Fatalf("pack/room = %v", res)
|
||||
}
|
||||
got := waitSignal(t, fb, "pack/room")
|
||||
if got["sender"] != "a" {
|
||||
t.Fatalf("pack/room sender = %v, want a", got["sender"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataTransferPackToAutoPairs(t *testing.T) {
|
||||
hub := newHub()
|
||||
a, _ := connect(hub, "a")
|
||||
b, fb := connect(hub, "b")
|
||||
|
||||
res := asMap(t, hub.Handle(a, msg("pack/to", "to", "b", "pack", map[string]any{"hi": true}, "handshake", true)))
|
||||
if res["type"] != "success" {
|
||||
t.Fatalf("pack/to = %v", res)
|
||||
}
|
||||
got := waitSignal(t, fb, "pack")
|
||||
if got["from"] != "a" {
|
||||
t.Fatalf("pack from = %v, want a", got["from"])
|
||||
}
|
||||
// Auto-pairing should have linked both sides.
|
||||
if !a.HasPair("b") || !b.HasPair("a") {
|
||||
t.Fatal("pack/to should auto-pair when the target does not require pairing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionFlagGatesPackReadable(t *testing.T) {
|
||||
hub := newHub()
|
||||
a, _ := connect(hub, "a")
|
||||
connect(hub, "b")
|
||||
|
||||
// a disables sending; pack/to must now fail the handshake.
|
||||
if r := asMap(t, hub.Handle(a, msg("connection/packsending", "value", float64(0)))); r["status"] != "success" {
|
||||
t.Fatalf("connection/packsending = %v", r)
|
||||
}
|
||||
res := asMap(t, hub.Handle(a, msg("pack/to", "to", "b", "pack", map[string]any{}, "handshake", true)))
|
||||
if res["type"] != "fail" {
|
||||
t.Fatalf("pack/to after disabling send = %v, want fail", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPPressureAllocatesUniqueAddresses(t *testing.T) {
|
||||
hub := newHub()
|
||||
a, _ := connect(hub, "a")
|
||||
b, _ := connect(hub, "b")
|
||||
|
||||
na := asMap(t, hub.Handle(a, msg("alloc/APNumber")))["number"].(int)
|
||||
nb := asMap(t, hub.Handle(b, msg("alloc/APNumber")))["number"].(int)
|
||||
if na == nb {
|
||||
t.Fatalf("AP numbers must be unique, both got %d", na)
|
||||
}
|
||||
|
||||
who := asMap(t, hub.Handle(a, msg("whois/APNumber", "whois", float64(na))))
|
||||
if who["socket"] != "a" {
|
||||
t.Fatalf("whois APNumber = %v, want a", who)
|
||||
}
|
||||
|
||||
ipA := asMap(t, hub.Handle(a, msg("alloc/APIPAddress")))["ip"].(string)
|
||||
ipB := asMap(t, hub.Handle(b, msg("alloc/APIPAddress")))["ip"].(string)
|
||||
if ipA == ipB {
|
||||
t.Fatalf("AP ips must be unique, both got %s", ipA)
|
||||
}
|
||||
|
||||
// Releasing frees the number for reuse.
|
||||
if r := asMap(t, hub.Handle(a, msg("release/APNumber"))); r["status"] != "success" {
|
||||
t.Fatalf("release/APNumber = %v", r)
|
||||
}
|
||||
if a.APNumber() != 0 {
|
||||
t.Fatal("released number should be cleared on the client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisconnectCleansRoomsAndPairs(t *testing.T) {
|
||||
hub := newHub()
|
||||
a, _ := connect(hub, "a")
|
||||
b, fb := connect(hub, "b")
|
||||
|
||||
// Put both in a shared room and pair them.
|
||||
hub.Handle(a, msg("create-room",
|
||||
"accessType", "public", "joinType", "free",
|
||||
"notifyActionInvite", false, "notifyActionJoined", true, "notifyActionEjected", true,
|
||||
"description", "d", "name", "R1",
|
||||
))
|
||||
hub.Handle(b, msg("joinroom", "name", "R1"))
|
||||
hub.Handle(a, msg("request/pair", "to", "b"))
|
||||
hub.Handle(b, msg("accept/pair", "to", "a"))
|
||||
if !a.HasPair("b") {
|
||||
t.Fatal("precondition: a should be paired with b")
|
||||
}
|
||||
|
||||
hub.Disconnect(a)
|
||||
|
||||
waitSignal(t, fb, "peer/disconnect")
|
||||
waitSignal(t, fb, "room/ejected")
|
||||
|
||||
if _, ok := hub.Client("a"); ok {
|
||||
t.Fatal("disconnected client should be removed from the hub")
|
||||
}
|
||||
if b.HasPair("a") {
|
||||
t.Fatal("pair edge to the disconnected client should be gone")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"git.saqut.com/saqut/mwse/internal/protocol"
|
||||
"git.saqut.com/saqut/mwse/internal/ws"
|
||||
)
|
||||
|
||||
// registerSession ports the Session service: per-connection feature flags that
|
||||
// gate notifications and data relay. Defaults are already applied in
|
||||
// ws.NewClient; the connect hook re-applies them for parity with the original.
|
||||
//
|
||||
// The SDK sends these toggles as numbers (value: 1 / value: 0), so Truthy is used
|
||||
// to match the original `!!msg.value` coercion.
|
||||
func registerSession(hub *ws.Hub) {
|
||||
hub.OnConnect(func(c *ws.Client) { c.ResetStore() })
|
||||
|
||||
flag := func(name string) handler {
|
||||
return func(c *ws.Client, m protocol.Message) any {
|
||||
c.SetStore(name, m.Truthy("value"))
|
||||
return success()
|
||||
}
|
||||
}
|
||||
|
||||
hub.Register("connection/pairinfo", flag("notifyPairInfo"))
|
||||
hub.Register("connection/roominfo", flag("notifyRoomInfo"))
|
||||
hub.Register("connection/packrecaive", flag("packrecaive"))
|
||||
hub.Register("connection/packsending", flag("packsending"))
|
||||
|
||||
hub.Register("connection/reset", func(c *ws.Client, m protocol.Message) any {
|
||||
c.ResetStore()
|
||||
return success()
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package services
|
||||
|
||||
import "git.saqut.com/saqut/mwse/internal/ws"
|
||||
|
||||
// registerYourID tells a freshly connected client its own socket id, exactly as
|
||||
// the Node YourID service did: client.send([{type:'id', value: id}, 'id']).
|
||||
func registerYourID(hub *ws.Hub) {
|
||||
hub.OnConnect(func(c *ws.Client) {
|
||||
c.Signal("id", map[string]any{"type": "id", "value": c.ID})
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
// Package testutil provides an in-memory WebSocket connection so the engine's
|
||||
// concurrency tests (#26) can run without real sockets. FakeConn satisfies the
|
||||
// ws.Conn interface structurally.
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// textMessage mirrors gorilla/websocket.TextMessage (1) without importing it, so
|
||||
// this helper stays dependency-light.
|
||||
const textMessage = 1
|
||||
|
||||
// FakeConn is a thread-safe, in-memory connection. Outbound frames are captured
|
||||
// in Writes(); inbound frames can be injected with Push() and are returned by
|
||||
// ReadMessage in order until the connection is closed.
|
||||
type FakeConn struct {
|
||||
mu sync.Mutex
|
||||
writes [][]byte
|
||||
incoming chan []byte
|
||||
closed bool
|
||||
pong func(string) error
|
||||
}
|
||||
|
||||
// NewFakeConn returns a ready connection.
|
||||
func NewFakeConn() *FakeConn {
|
||||
return &FakeConn{incoming: make(chan []byte, 1024)}
|
||||
}
|
||||
|
||||
// ReadMessage returns the next injected frame, blocking until one is available or
|
||||
// the connection is closed (then io.EOF).
|
||||
func (f *FakeConn) ReadMessage() (int, []byte, error) {
|
||||
b, ok := <-f.incoming
|
||||
if !ok {
|
||||
return 0, nil, io.EOF
|
||||
}
|
||||
return textMessage, b, nil
|
||||
}
|
||||
|
||||
// Push injects an inbound frame for ReadMessage to return.
|
||||
func (f *FakeConn) Push(frame []byte) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.closed {
|
||||
return
|
||||
}
|
||||
f.incoming <- frame
|
||||
}
|
||||
|
||||
// WriteMessage captures an outbound frame.
|
||||
func (f *FakeConn) WriteMessage(_ int, data []byte) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.closed {
|
||||
return io.ErrClosedPipe
|
||||
}
|
||||
cp := make([]byte, len(data))
|
||||
copy(cp, data)
|
||||
f.writes = append(f.writes, cp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Writes returns a copy of all captured outbound frames.
|
||||
func (f *FakeConn) Writes() [][]byte {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make([][]byte, len(f.writes))
|
||||
copy(out, f.writes)
|
||||
return out
|
||||
}
|
||||
|
||||
// WriteCount returns how many frames have been written.
|
||||
func (f *FakeConn) WriteCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.writes)
|
||||
}
|
||||
|
||||
func (f *FakeConn) WriteControl(int, []byte, time.Time) error { return nil }
|
||||
func (f *FakeConn) SetReadLimit(int64) {}
|
||||
func (f *FakeConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (f *FakeConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
func (f *FakeConn) SetPongHandler(h func(string) error) { f.pong = h }
|
||||
|
||||
// Pong invokes the registered pong handler (used by heartbeat tests).
|
||||
func (f *FakeConn) Pong(appData string) error {
|
||||
if f.pong != nil {
|
||||
return f.pong(appData)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close makes ReadMessage return EOF and rejects further writes. Idempotent.
|
||||
func (f *FakeConn) Close() error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.closed {
|
||||
return nil
|
||||
}
|
||||
f.closed = true
|
||||
close(f.incoming)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,346 @@
|
|||
package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"git.saqut.com/saqut/mwse/internal/protocol"
|
||||
)
|
||||
|
||||
// outboundBuffer bounds how many frames may queue for one slow client before the
|
||||
// engine gives up and disconnects it. This is the classic gorilla "hub" backpressure
|
||||
// policy: a peer that cannot keep up is dropped rather than allowed to grow memory
|
||||
// without limit or stall a broadcast.
|
||||
const outboundBuffer = 256
|
||||
|
||||
// Session flag keys. They live in the Client.store map and mirror the Node
|
||||
// Session service defaults exactly.
|
||||
const (
|
||||
flagNotifyPairInfo = "notifyPairInfo"
|
||||
flagPackReceive = "packrecaive" // (sic) original spelling kept for parity
|
||||
flagPackSending = "packsending"
|
||||
flagNotifyRoomInfo = "notifyRoomInfo"
|
||||
)
|
||||
|
||||
// Client is one connected peer.
|
||||
//
|
||||
// Concurrency model (the whole point of the Go rewrite, see #22):
|
||||
//
|
||||
// - All socket WRITES go through a single writer goroutine draining `outbound`.
|
||||
// Producers never touch the socket, so concurrent writes are impossible.
|
||||
// - `Send` enqueues onto `outbound` but always also selects on `done`, so a send
|
||||
// racing a disconnect is harmlessly dropped instead of writing to a dead peer.
|
||||
// - All mutable peer STATE (info, store, rooms, pairs, ...) is guarded by `mu`.
|
||||
// A peer leaving (clearing its state) and another goroutine reading/sending to
|
||||
// it are therefore serialized; the "leave-while-send" race cannot occur.
|
||||
type Client struct {
|
||||
ID string
|
||||
CreatedAt time.Time
|
||||
|
||||
conn Conn
|
||||
outbound chan []byte
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
dropped uint64 // frames dropped due to a full outbound buffer (atomic)
|
||||
|
||||
mu sync.RWMutex
|
||||
info map[string]any // application metadata, shared with paired/room peers
|
||||
store map[string]bool // per-connection session flags
|
||||
rooms map[string]struct{} // rooms this client belongs to
|
||||
pairs map[string]struct{} // peers this client has paired with
|
||||
requiredPair bool // when true, others must be paired to reach this client
|
||||
|
||||
apNumber int // virtual address: short number
|
||||
apShortCode string // virtual address: 3-letter code
|
||||
apIP string // virtual address: 10.x.x.x style ip
|
||||
}
|
||||
|
||||
// NewClient wraps an accepted connection. Session flag defaults are applied here
|
||||
// (rather than only via the Session service's connect hook) so they are always
|
||||
// present regardless of listener ordering.
|
||||
func NewClient(conn Conn, id string) *Client {
|
||||
return &Client{
|
||||
ID: id,
|
||||
CreatedAt: time.Now(),
|
||||
conn: conn,
|
||||
outbound: make(chan []byte, outboundBuffer),
|
||||
done: make(chan struct{}),
|
||||
info: make(map[string]any),
|
||||
store: map[string]bool{
|
||||
flagNotifyPairInfo: true,
|
||||
flagPackReceive: true,
|
||||
flagPackSending: true,
|
||||
flagNotifyRoomInfo: true,
|
||||
},
|
||||
rooms: make(map[string]struct{}),
|
||||
pairs: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- sending -------------------------------------------------------------
|
||||
|
||||
// Send marshals v and enqueues it for the writer goroutine. It is safe to call
|
||||
// from any goroutine and at any time, and it never blocks:
|
||||
//
|
||||
// - if the client is closing, the frame is dropped (this is the branch that
|
||||
// makes "send to a peer that is leaving" safe instead of a race/panic);
|
||||
// - if the outbound buffer is full, this frame is dropped but the connection is
|
||||
// kept. MWSE is a best-effort relay, so a momentarily slow consumer should
|
||||
// lose a frame, not be disconnected (which would cascade under load). A
|
||||
// genuinely dead consumer is still reaped: its writer eventually trips the
|
||||
// write deadline, which ends the read loop and disconnects it.
|
||||
func (c *Client) Send(v any) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case c.outbound <- b:
|
||||
case <-c.done:
|
||||
// Client is gone; drop silently.
|
||||
default:
|
||||
// Buffer full; drop this frame, keep the connection.
|
||||
atomic.AddUint64(&c.dropped, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Dropped returns the number of frames dropped because the outbound buffer was
|
||||
// full. Useful for load tests and operational metrics.
|
||||
func (c *Client) Dropped() uint64 { return atomic.LoadUint64(&c.dropped) }
|
||||
|
||||
// Signal sends a server-initiated message [payload, name] to this client.
|
||||
func (c *Client) Signal(name string, payload any) {
|
||||
c.Send(protocol.Signal(name, payload))
|
||||
}
|
||||
|
||||
// ---- pumps ---------------------------------------------------------------
|
||||
|
||||
// writePump is the ONLY goroutine that calls conn.WriteMessage. It exits when the
|
||||
// client is closed, closing the socket on the way out.
|
||||
func (c *Client) writePump() {
|
||||
defer c.conn.Close()
|
||||
for {
|
||||
select {
|
||||
case b := <-c.outbound:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(defaultWriteWait))
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, b); err != nil {
|
||||
return
|
||||
}
|
||||
case <-c.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close tears the client's transport down exactly once. It does NOT run the
|
||||
// logical disconnect (room/pair cleanup) — that is driven by the read loop's exit
|
||||
// in server.go, so it always happens precisely once per connection.
|
||||
func (c *Client) Close() {
|
||||
c.closeOnce.Do(func() {
|
||||
// Best-effort polite close frame; ignore errors (peer may already be gone).
|
||||
_ = c.conn.WriteControl(
|
||||
websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
|
||||
time.Now().Add(time.Second),
|
||||
)
|
||||
close(c.done)
|
||||
})
|
||||
}
|
||||
|
||||
// Done exposes the close signal for select-based waits (used by the ping loop).
|
||||
func (c *Client) Done() <-chan struct{} { return c.done }
|
||||
|
||||
// ---- guarded state accessors --------------------------------------------
|
||||
|
||||
// SetInfo stores an application metadata value.
|
||||
func (c *Client) SetInfo(name string, value any) {
|
||||
c.mu.Lock()
|
||||
c.info[name] = value
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// InfoValue returns a single metadata value.
|
||||
func (c *Client) InfoValue(name string) (any, bool) {
|
||||
c.mu.RLock()
|
||||
v, ok := c.info[name]
|
||||
c.mu.RUnlock()
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// Info returns a copy of all metadata. A copy is returned so callers can range
|
||||
// over it without holding the lock.
|
||||
func (c *Client) Info() map[string]any {
|
||||
c.mu.RLock()
|
||||
out := make(map[string]any, len(c.info))
|
||||
for k, v := range c.info {
|
||||
out[k] = v
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
return out
|
||||
}
|
||||
|
||||
// Match reports whether every key/value in filter is present and equal in this
|
||||
// client's info (the room peer filter from Node's Client.match).
|
||||
func (c *Client) Match(filter map[string]any) bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if len(filter) > len(c.info) {
|
||||
return false
|
||||
}
|
||||
for k, want := range filter {
|
||||
got, ok := c.info[k]
|
||||
if !ok || got != want {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// SetStore sets a session flag.
|
||||
func (c *Client) SetStore(name string, v bool) {
|
||||
c.mu.Lock()
|
||||
c.store[name] = v
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// store flag readers, named to match the original Client helpers.
|
||||
func (c *Client) PackWriteable() bool { return c.storeFlag(flagPackReceive) }
|
||||
func (c *Client) PackReadable() bool { return c.storeFlag(flagPackSending) }
|
||||
func (c *Client) PeerInfoNotifiable() bool { return c.storeFlag(flagNotifyPairInfo) }
|
||||
func (c *Client) RoomInfoNotifiable() bool { return c.storeFlag(flagNotifyRoomInfo) }
|
||||
|
||||
func (c *Client) storeFlag(name string) bool {
|
||||
c.mu.RLock()
|
||||
v := c.store[name]
|
||||
c.mu.RUnlock()
|
||||
return v
|
||||
}
|
||||
|
||||
// ResetStore restores the default session flags.
|
||||
func (c *Client) ResetStore() {
|
||||
c.mu.Lock()
|
||||
c.store[flagNotifyPairInfo] = true
|
||||
c.store[flagPackReceive] = true
|
||||
c.store[flagPackSending] = true
|
||||
c.store[flagNotifyRoomInfo] = true
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// RequiredPair / SetRequiredPair toggle the "private" reachability mode.
|
||||
func (c *Client) RequiredPair() bool {
|
||||
c.mu.RLock()
|
||||
v := c.requiredPair
|
||||
c.mu.RUnlock()
|
||||
return v
|
||||
}
|
||||
|
||||
func (c *Client) SetRequiredPair(v bool) {
|
||||
c.mu.Lock()
|
||||
c.requiredPair = v
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// ---- room membership -----------------------------------------------------
|
||||
|
||||
func (c *Client) addRoom(id string) {
|
||||
c.mu.Lock()
|
||||
c.rooms[id] = struct{}{}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Client) removeRoom(id string) {
|
||||
c.mu.Lock()
|
||||
delete(c.rooms, id)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// InRoom reports membership.
|
||||
func (c *Client) InRoom(id string) bool {
|
||||
c.mu.RLock()
|
||||
_, ok := c.rooms[id]
|
||||
c.mu.RUnlock()
|
||||
return ok
|
||||
}
|
||||
|
||||
// Rooms returns a snapshot of room ids this client belongs to.
|
||||
func (c *Client) Rooms() []string {
|
||||
c.mu.RLock()
|
||||
out := make([]string, 0, len(c.rooms))
|
||||
for id := range c.rooms {
|
||||
out = append(out, id)
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- pairing -------------------------------------------------------------
|
||||
|
||||
// AddPair records that this client has paired toward other.
|
||||
func (c *Client) AddPair(other string) {
|
||||
c.mu.Lock()
|
||||
c.pairs[other] = struct{}{}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// RemovePair drops a pairing edge from this client.
|
||||
func (c *Client) RemovePair(other string) {
|
||||
c.mu.Lock()
|
||||
delete(c.pairs, other)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// HasPair reports whether this client has a pairing edge toward other.
|
||||
func (c *Client) HasPair(other string) bool {
|
||||
c.mu.RLock()
|
||||
_, ok := c.pairs[other]
|
||||
c.mu.RUnlock()
|
||||
return ok
|
||||
}
|
||||
|
||||
// Pairs returns a snapshot of this client's pairing edges.
|
||||
func (c *Client) Pairs() []string {
|
||||
c.mu.RLock()
|
||||
out := make([]string, 0, len(c.pairs))
|
||||
for id := range c.pairs {
|
||||
out = append(out, id)
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- virtual address (IPPressure) ---------------------------------------
|
||||
|
||||
func (c *Client) APNumber() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.apNumber
|
||||
}
|
||||
func (c *Client) SetAPNumber(v int) {
|
||||
c.mu.Lock()
|
||||
c.apNumber = v
|
||||
c.mu.Unlock()
|
||||
}
|
||||
func (c *Client) APShortCode() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.apShortCode
|
||||
}
|
||||
func (c *Client) SetAPShortCode(v string) {
|
||||
c.mu.Lock()
|
||||
c.apShortCode = v
|
||||
c.mu.Unlock()
|
||||
}
|
||||
func (c *Client) APIP() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.apIP
|
||||
}
|
||||
func (c *Client) SetAPIP(v string) {
|
||||
c.mu.Lock()
|
||||
c.apIP = v
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package ws
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Conn is the minimal slice of *gorilla/websocket.Conn that the engine relies on.
|
||||
// Depending on an interface (rather than the concrete type) lets the tests drive
|
||||
// a Client with an in-memory fake connection, so the concurrency tests in #26 do
|
||||
// not need real sockets.
|
||||
//
|
||||
// gorilla's contract: at most one goroutine may call the write methods at a time
|
||||
// and at most one may call the read methods at a time, BUT Close and WriteControl
|
||||
// may be called concurrently with everything else. The Client honours this by
|
||||
// funnelling all WriteMessage calls through a single writer goroutine, while the
|
||||
// ping loop uses WriteControl and shutdown uses Close.
|
||||
type Conn interface {
|
||||
ReadMessage() (messageType int, p []byte, err error)
|
||||
WriteMessage(messageType int, data []byte) error
|
||||
WriteControl(messageType int, data []byte, deadline time.Time) error
|
||||
SetReadLimit(limit int64)
|
||||
SetReadDeadline(t time.Time) error
|
||||
SetWriteDeadline(t time.Time) error
|
||||
SetPongHandler(h func(appData string) error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// newUUID returns a random RFC 4122 version 4 UUID. The original server used
|
||||
// Node's crypto.randomUUID(); this keeps client ids in the same shape without a
|
||||
// third-party dependency.
|
||||
func newUUID() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// crypto/rand failing is catastrophic and not something a relay can recover
|
||||
// from sensibly; surface it loudly rather than handing out a zero id.
|
||||
panic(fmt.Sprintf("ws: cannot read random bytes for uuid: %v", err))
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40 // version 4
|
||||
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
package ws
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"git.saqut.com/saqut/mwse/internal/protocol"
|
||||
)
|
||||
|
||||
// Handler processes one inbound message and returns the value to reply with.
|
||||
// Returning nil is allowed (it becomes JSON null, as `undefined` did in Node).
|
||||
type Handler func(c *Client, m protocol.Message) any
|
||||
|
||||
// Listener is notified of a connection lifecycle event.
|
||||
type Listener func(c *Client)
|
||||
|
||||
// Hub is the engine's shared state: the client and room registries, the message
|
||||
// router, and the connect/disconnect event bus. Every map is guarded by its own
|
||||
// RWMutex so unrelated subsystems never contend on one another.
|
||||
type Hub struct {
|
||||
cmu sync.RWMutex
|
||||
clients map[string]*Client
|
||||
|
||||
rmu sync.RWMutex
|
||||
rooms map[string]*Room
|
||||
|
||||
hmu sync.RWMutex
|
||||
handlers map[string]Handler
|
||||
|
||||
lmu sync.RWMutex
|
||||
onConnect []Listener
|
||||
onDisconnect []Listener
|
||||
}
|
||||
|
||||
// NewHub returns an empty hub.
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
clients: make(map[string]*Client),
|
||||
rooms: make(map[string]*Room),
|
||||
handlers: make(map[string]Handler),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- router --------------------------------------------------------------
|
||||
|
||||
// Register binds a message type to a handler. Re-registering a type overwrites it.
|
||||
func (h *Hub) Register(msgType string, handler Handler) {
|
||||
h.hmu.Lock()
|
||||
h.handlers[msgType] = handler
|
||||
h.hmu.Unlock()
|
||||
}
|
||||
|
||||
// HasHandler reports whether a type is registered (used by tests).
|
||||
func (h *Hub) HasHandler(msgType string) bool {
|
||||
h.hmu.RLock()
|
||||
_, ok := h.handlers[msgType]
|
||||
h.hmu.RUnlock()
|
||||
return ok
|
||||
}
|
||||
|
||||
// Handle routes a message to its handler, mirroring the Node MessageRouter
|
||||
// (MISSING_TYPE / UNKNOWN_TYPE / HANDLER_ERROR), and recovers from handler panics
|
||||
// so one bad message can never take down the connection or the process.
|
||||
func (h *Hub) Handle(c *Client, m protocol.Message) (result any) {
|
||||
t := m.Type()
|
||||
if t == "" {
|
||||
return failMsg("MISSING_TYPE")
|
||||
}
|
||||
h.hmu.RLock()
|
||||
handler, ok := h.handlers[t]
|
||||
h.hmu.RUnlock()
|
||||
if !ok {
|
||||
return failMsg("UNKNOWN_TYPE")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("handler panic [%s]: %v", t, r)
|
||||
result = map[string]any{"status": "fail", "message": "HANDLER_ERROR"}
|
||||
}
|
||||
}()
|
||||
return handler(c, m)
|
||||
}
|
||||
|
||||
func failMsg(msg string) map[string]any {
|
||||
return map[string]any{"status": "fail", "message": msg}
|
||||
}
|
||||
|
||||
// ---- client registry -----------------------------------------------------
|
||||
|
||||
func (h *Hub) addClient(c *Client) {
|
||||
h.cmu.Lock()
|
||||
h.clients[c.ID] = c
|
||||
h.cmu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Hub) removeClient(id string) {
|
||||
h.cmu.Lock()
|
||||
delete(h.clients, id)
|
||||
h.cmu.Unlock()
|
||||
}
|
||||
|
||||
// Client looks up a connected client by id.
|
||||
func (h *Hub) Client(id string) (*Client, bool) {
|
||||
h.cmu.RLock()
|
||||
c, ok := h.clients[id]
|
||||
h.cmu.RUnlock()
|
||||
return c, ok
|
||||
}
|
||||
|
||||
// Clients returns a snapshot of all connected clients.
|
||||
func (h *Hub) Clients() []*Client {
|
||||
h.cmu.RLock()
|
||||
out := make([]*Client, 0, len(h.clients))
|
||||
for _, c := range h.clients {
|
||||
out = append(out, c)
|
||||
}
|
||||
h.cmu.RUnlock()
|
||||
return out
|
||||
}
|
||||
|
||||
// ClientCount returns the number of connected clients.
|
||||
func (h *Hub) ClientCount() int {
|
||||
h.cmu.RLock()
|
||||
n := len(h.clients)
|
||||
h.cmu.RUnlock()
|
||||
return n
|
||||
}
|
||||
|
||||
// ---- room registry -------------------------------------------------------
|
||||
|
||||
func (h *Hub) addRoom(r *Room) {
|
||||
h.rmu.Lock()
|
||||
h.rooms[r.ID] = r
|
||||
h.rmu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Hub) removeRoom(id string) {
|
||||
h.rmu.Lock()
|
||||
delete(h.rooms, id)
|
||||
h.rmu.Unlock()
|
||||
}
|
||||
|
||||
// Room looks up a room by id.
|
||||
func (h *Hub) Room(id string) (*Room, bool) {
|
||||
h.rmu.RLock()
|
||||
r, ok := h.rooms[id]
|
||||
h.rmu.RUnlock()
|
||||
return r, ok
|
||||
}
|
||||
|
||||
// Rooms returns a snapshot of all rooms.
|
||||
func (h *Hub) Rooms() []*Room {
|
||||
h.rmu.RLock()
|
||||
out := make([]*Room, 0, len(h.rooms))
|
||||
for _, r := range h.rooms {
|
||||
out = append(out, r)
|
||||
}
|
||||
h.rmu.RUnlock()
|
||||
return out
|
||||
}
|
||||
|
||||
// RoomByName returns the first room with the given name. Room names are not
|
||||
// unique by construction, so this matches the original "first match wins" lookup.
|
||||
func (h *Hub) RoomByName(name string) (*Room, bool) {
|
||||
h.rmu.RLock()
|
||||
defer h.rmu.RUnlock()
|
||||
for _, r := range h.rooms {
|
||||
if r.Name == name {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ---- event bus -----------------------------------------------------------
|
||||
|
||||
// OnConnect registers a listener fired after a client connects.
|
||||
func (h *Hub) OnConnect(l Listener) {
|
||||
h.lmu.Lock()
|
||||
h.onConnect = append(h.onConnect, l)
|
||||
h.lmu.Unlock()
|
||||
}
|
||||
|
||||
// OnDisconnect registers a listener fired when a client disconnects.
|
||||
func (h *Hub) OnDisconnect(l Listener) {
|
||||
h.lmu.Lock()
|
||||
h.onDisconnect = append(h.onDisconnect, l)
|
||||
h.lmu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Hub) emitConnect(c *Client) {
|
||||
h.lmu.RLock()
|
||||
listeners := append([]Listener(nil), h.onConnect...)
|
||||
h.lmu.RUnlock()
|
||||
for _, l := range listeners {
|
||||
l(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) emitDisconnect(c *Client) {
|
||||
h.lmu.RLock()
|
||||
listeners := append([]Listener(nil), h.onDisconnect...)
|
||||
h.lmu.RUnlock()
|
||||
for _, l := range listeners {
|
||||
l(c)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
package ws
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Room is a set of clients that can be addressed together. Access types and join
|
||||
// types mirror the original service.
|
||||
type Room struct {
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
OwnerID string
|
||||
CreatedAt time.Time
|
||||
AccessType string // "public" | "private"
|
||||
JoinType string // "free" | "invite" | "password" | "lock" | "notify"
|
||||
|
||||
NotifyActionInvite bool
|
||||
NotifyActionJoined bool
|
||||
NotifyActionEjected bool
|
||||
|
||||
Credential string // sha256 hex, or "" when none
|
||||
|
||||
hub *Hub
|
||||
|
||||
mu sync.RWMutex
|
||||
clients map[string]*Client
|
||||
waitingInvited map[string]struct{}
|
||||
info map[string]any
|
||||
}
|
||||
|
||||
// NewRoom creates an empty, unpublished room with a fresh id and the same field
|
||||
// defaults the Node constructor used.
|
||||
func NewRoom(hub *Hub) *Room {
|
||||
return &Room{
|
||||
ID: newUUID(),
|
||||
CreatedAt: time.Now(),
|
||||
JoinType: "invite",
|
||||
NotifyActionJoined: true,
|
||||
NotifyActionEjected: true,
|
||||
hub: hub,
|
||||
clients: make(map[string]*Client),
|
||||
waitingInvited: make(map[string]struct{}),
|
||||
info: make(map[string]any),
|
||||
}
|
||||
}
|
||||
|
||||
// Publish registers the room in the hub so it can be looked up by id.
|
||||
func (r *Room) Publish() { r.hub.addRoom(r) }
|
||||
|
||||
// Size returns the current member count.
|
||||
func (r *Room) Size() int {
|
||||
r.mu.RLock()
|
||||
n := len(r.clients)
|
||||
r.mu.RUnlock()
|
||||
return n
|
||||
}
|
||||
|
||||
// Has reports membership by client id.
|
||||
func (r *Room) Has(id string) bool {
|
||||
r.mu.RLock()
|
||||
_, ok := r.clients[id]
|
||||
r.mu.RUnlock()
|
||||
return ok
|
||||
}
|
||||
|
||||
// snapshot returns the current members as a slice. The room lock is held only for
|
||||
// the cheap copy; callers then send without holding any lock, so a member that
|
||||
// disconnects mid-broadcast cannot deadlock or race the send (Client.Send drops
|
||||
// safely once the peer is closing).
|
||||
func (r *Room) snapshot() []*Client {
|
||||
r.mu.RLock()
|
||||
out := make([]*Client, 0, len(r.clients))
|
||||
for _, c := range r.clients {
|
||||
out = append(out, c)
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
return out
|
||||
}
|
||||
|
||||
// Broadcast sends signal `name` with `payload` to every member except exceptID
|
||||
// (pass "" to include everyone). If filter is non-nil, only members for which it
|
||||
// returns true receive the message.
|
||||
func (r *Room) Broadcast(name string, payload any, exceptID string, filter func(*Client) bool) {
|
||||
for _, c := range r.snapshot() {
|
||||
if c.ID == exceptID {
|
||||
continue
|
||||
}
|
||||
if filter != nil && !filter(c) {
|
||||
continue
|
||||
}
|
||||
c.Signal(name, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// Members returns a snapshot of the room's current members.
|
||||
func (r *Room) Members() []*Client { return r.snapshot() }
|
||||
|
||||
// FilterPeers returns the members whose info matches filter.
|
||||
func (r *Room) FilterPeers(filter map[string]any) []*Client {
|
||||
var out []*Client
|
||||
for _, c := range r.snapshot() {
|
||||
if c.Match(filter) {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Join adds a client to the room. Existing members are notified first (so the new
|
||||
// member does not receive its own join), then membership is recorded on both the
|
||||
// room and the client.
|
||||
func (r *Room) Join(c *Client) {
|
||||
if r.NotifyActionJoined {
|
||||
r.Broadcast(
|
||||
"room/joined",
|
||||
map[string]any{"id": c.ID, "roomid": r.ID, "ownerid": r.OwnerID},
|
||||
"",
|
||||
(*Client).PeerInfoNotifiable,
|
||||
)
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.clients[c.ID] = c
|
||||
r.mu.Unlock()
|
||||
c.addRoom(r.ID)
|
||||
}
|
||||
|
||||
// Eject removes a client from the room, notifying the remaining members. When the
|
||||
// room empties it is taken down.
|
||||
func (r *Room) Eject(c *Client) {
|
||||
if r.NotifyActionEjected {
|
||||
r.Broadcast(
|
||||
"room/ejected",
|
||||
map[string]any{"id": c.ID, "roomid": r.ID, "ownerid": r.OwnerID},
|
||||
c.ID,
|
||||
(*Client).PeerInfoNotifiable,
|
||||
)
|
||||
}
|
||||
c.removeRoom(r.ID)
|
||||
r.mu.Lock()
|
||||
delete(r.clients, c.ID)
|
||||
empty := len(r.clients) == 0
|
||||
r.mu.Unlock()
|
||||
|
||||
if empty {
|
||||
r.Down()
|
||||
}
|
||||
}
|
||||
|
||||
// Down closes the room: members are told, the room is unregistered, and each
|
||||
// member's membership record is cleared.
|
||||
func (r *Room) Down() {
|
||||
members := r.snapshot()
|
||||
for _, c := range members {
|
||||
c.Signal("room/closed", map[string]any{"roomid": r.ID, "ownerid": r.OwnerID})
|
||||
c.removeRoom(r.ID)
|
||||
}
|
||||
r.hub.removeRoom(r.ID)
|
||||
}
|
||||
|
||||
// ---- room info -----------------------------------------------------------
|
||||
|
||||
// SetInfo stores a room-level value.
|
||||
func (r *Room) SetInfo(name string, value any) {
|
||||
r.mu.Lock()
|
||||
r.info[name] = value
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// InfoValue returns a single room value.
|
||||
func (r *Room) InfoValue(name string) (any, bool) {
|
||||
r.mu.RLock()
|
||||
v, ok := r.info[name]
|
||||
r.mu.RUnlock()
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// Info returns a copy of all room values.
|
||||
func (r *Room) Info() map[string]any {
|
||||
r.mu.RLock()
|
||||
out := make(map[string]any, len(r.info))
|
||||
for k, v := range r.info {
|
||||
out[k] = v
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- invitations ---------------------------------------------------------
|
||||
|
||||
// AddWaiting records a client awaiting an invite decision.
|
||||
func (r *Room) AddWaiting(id string) {
|
||||
r.mu.Lock()
|
||||
r.waitingInvited[id] = struct{}{}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// RemoveWaiting drops a client from the invite waiting list.
|
||||
func (r *Room) RemoveWaiting(id string) {
|
||||
r.mu.Lock()
|
||||
delete(r.waitingInvited, id)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// IsWaiting reports whether a client is on the invite waiting list.
|
||||
func (r *Room) IsWaiting(id string) bool {
|
||||
r.mu.RLock()
|
||||
_, ok := r.waitingInvited[id]
|
||||
r.mu.RUnlock()
|
||||
return ok
|
||||
}
|
||||
|
||||
func (r *Room) waitingList() []string {
|
||||
r.mu.RLock()
|
||||
out := make([]string, 0, len(r.waitingInvited))
|
||||
for id := range r.waitingInvited {
|
||||
out = append(out, id)
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- serialization -------------------------------------------------------
|
||||
|
||||
// ToJSON renders the room the way the SDK expects. When detailed is true the
|
||||
// sensitive/owner-only fields are included.
|
||||
func (r *Room) ToJSON(detailed bool) map[string]any {
|
||||
obj := map[string]any{
|
||||
"id": r.ID,
|
||||
"accessType": r.AccessType,
|
||||
"createdAt": r.CreatedAt,
|
||||
"description": r.Description,
|
||||
"joinType": r.JoinType,
|
||||
"name": r.Name,
|
||||
"owner": r.OwnerID,
|
||||
"waitingInvited": r.waitingList(),
|
||||
}
|
||||
if detailed {
|
||||
obj["credential"] = r.Credential
|
||||
obj["notifyActionInvite"] = r.NotifyActionInvite
|
||||
obj["notifyActionJoined"] = r.NotifyActionJoined
|
||||
obj["notifyActionEjected"] = r.NotifyActionEjected
|
||||
r.mu.RLock()
|
||||
ids := make([]string, 0, len(r.clients))
|
||||
for id := range r.clients {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
obj["clients"] = ids
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
package ws
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"git.saqut.com/saqut/mwse/internal/protocol"
|
||||
)
|
||||
|
||||
// Heartbeat and transport defaults. PingPeriod matches the original server's 10s
|
||||
// interval; the ping payload is the same magic string the SDK's pong echoes.
|
||||
const (
|
||||
defaultWriteWait = 10 * time.Second
|
||||
defaultPongWait = 60 * time.Second
|
||||
defaultPingPeriod = 10 * time.Second
|
||||
defaultMaxMessageSize = 4 << 20 // 4 MiB
|
||||
|
||||
pingPayload = "saQut"
|
||||
)
|
||||
|
||||
// Server upgrades HTTP requests to WebSocket connections and runs each one's
|
||||
// lifecycle. It holds no per-connection state itself; everything lives on the Hub.
|
||||
type Server struct {
|
||||
hub *Hub
|
||||
upgrader websocket.Upgrader
|
||||
pingPeriod time.Duration
|
||||
pongWait time.Duration
|
||||
}
|
||||
|
||||
// NewServer returns a Server bound to hub. CheckOrigin always returns true to
|
||||
// preserve the original autoAcceptConnections behaviour (origin policy is a
|
||||
// deployment concern handled in front of the engine).
|
||||
func NewServer(hub *Hub) *Server {
|
||||
return &Server{
|
||||
hub: hub,
|
||||
upgrader: websocket.Upgrader{
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
CheckOrigin: func(*http.Request) bool { return true },
|
||||
},
|
||||
pingPeriod: defaultPingPeriod,
|
||||
pongWait: defaultPongWait,
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler: it upgrades the request and hands the
|
||||
// connection to the lifecycle. It is the WebSocket endpoint.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := s.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("ws: upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
s.handle(conn)
|
||||
}
|
||||
|
||||
// handle drives one connection from accept to disconnect. It is generic over the
|
||||
// Conn interface so tests can feed it a scripted in-memory connection.
|
||||
func (s *Server) handle(conn Conn) {
|
||||
client := NewClient(conn, newUUID())
|
||||
|
||||
// Connect: register, start the writer, fire connect listeners (id, private
|
||||
// room, session defaults). Must happen before the read loop so the client can
|
||||
// already receive server-initiated messages.
|
||||
s.hub.Connect(client)
|
||||
|
||||
// Heartbeat lives in its own goroutine; it stops when the client closes.
|
||||
go s.pingLoop(client)
|
||||
|
||||
// Read loop blocks here until the socket errors or closes.
|
||||
s.readLoop(client)
|
||||
|
||||
// Disconnect runs exactly once, here, when the read loop ends.
|
||||
s.hub.Disconnect(client)
|
||||
}
|
||||
|
||||
// readLoop consumes inbound frames. Pongs that do not echo the magic payload drop
|
||||
// the connection (matching the original); a valid pong extends the read deadline.
|
||||
func (s *Server) readLoop(c *Client) {
|
||||
c.conn.SetReadLimit(defaultMaxMessageSize)
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(s.pongWait))
|
||||
c.conn.SetPongHandler(func(appData string) error {
|
||||
if appData != pingPayload {
|
||||
return errBadPong
|
||||
}
|
||||
return c.conn.SetReadDeadline(time.Now().Add(s.pongWait))
|
||||
})
|
||||
|
||||
for {
|
||||
msgType, data, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if msgType != websocket.TextMessage {
|
||||
continue // the protocol is JSON text; ignore binary frames
|
||||
}
|
||||
s.dispatch(c, data)
|
||||
}
|
||||
}
|
||||
|
||||
// errBadPong is returned by the pong handler to force a disconnect.
|
||||
var errBadPong = &pongError{}
|
||||
|
||||
type pongError struct{}
|
||||
|
||||
func (*pongError) Error() string { return "ws: pong validation failed" }
|
||||
|
||||
// dispatch decodes one frame and routes it, then replies according to the WSTS
|
||||
// rules. A frame that fails to decode is logged as a message error (the Node
|
||||
// server emitted a 'messageError' event here).
|
||||
func (s *Server) dispatch(c *Client, data []byte) {
|
||||
env, err := protocol.Decode(data)
|
||||
if err != nil {
|
||||
log.Printf("ws: message error from %s: %v", c.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
result := s.hub.Handle(c, env.Message)
|
||||
|
||||
if flag, ok := env.WantsReply(); ok {
|
||||
c.Send(protocol.Reply(result, env.ID, flag))
|
||||
return
|
||||
}
|
||||
|
||||
// "No id" branch: the original inspected the result for a broadcast directive.
|
||||
// No service currently emits one, but the hook is preserved for parity.
|
||||
if env.IsBroadcast() {
|
||||
if m, ok := result.(map[string]any); ok {
|
||||
if _, has := m["broadcast"]; has {
|
||||
log.Printf("ws: broadcast directive from %s (no listener registered)", c.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pingLoop sends a ping with the magic payload every ping period until the client
|
||||
// closes.
|
||||
func (s *Server) pingLoop(c *Client) {
|
||||
ticker := time.NewTicker(s.pingPeriod)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
err := c.conn.WriteControl(
|
||||
websocket.PingMessage,
|
||||
[]byte(pingPayload),
|
||||
time.Now().Add(defaultWriteWait),
|
||||
)
|
||||
if err != nil {
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
case <-c.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- lifecycle helpers (shared by the server and by tests) ---------------
|
||||
|
||||
// Connect registers a client, starts its writer goroutine, and fires the connect
|
||||
// listeners. Exposed so tests and tools can drive the same path the server uses.
|
||||
func (h *Hub) Connect(c *Client) {
|
||||
h.addClient(c)
|
||||
go c.writePump()
|
||||
h.emitConnect(c)
|
||||
}
|
||||
|
||||
// Disconnect fires the disconnect listeners, unregisters the client, and tears
|
||||
// its transport down. Safe to call once per client.
|
||||
func (h *Hub) Disconnect(c *Client) {
|
||||
h.emitDisconnect(c)
|
||||
h.removeClient(c.ID)
|
||||
c.Close()
|
||||
}
|
||||
|
||||
// CloseAll closes every connected client. Used during graceful shutdown so peers
|
||||
// receive a clean close frame before the process exits.
|
||||
func (h *Hub) CloseAll() {
|
||||
for _, c := range h.Clients() {
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.saqut.com/saqut/mwse/internal/protocol"
|
||||
"git.saqut.com/saqut/mwse/internal/testutil"
|
||||
)
|
||||
|
||||
// newTestClient builds a client backed by an in-memory connection and starts its
|
||||
// writer pump, the way the real server does.
|
||||
func newTestClient(id string) (*Client, *testutil.FakeConn) {
|
||||
fc := testutil.NewFakeConn()
|
||||
c := NewClient(fc, id)
|
||||
go c.writePump()
|
||||
return c, fc
|
||||
}
|
||||
|
||||
// waitFor polls cond until it holds or the deadline passes.
|
||||
func waitFor(t *testing.T, cond func() bool) {
|
||||
t.Helper()
|
||||
for i := 0; i < 500; i++ {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatal("condition not met within timeout")
|
||||
}
|
||||
|
||||
// lastSignal decodes the most recent [payload, name] frame from fc.
|
||||
func lastSignal(t *testing.T, fc *testutil.FakeConn) (string, map[string]any) {
|
||||
t.Helper()
|
||||
writes := fc.Writes()
|
||||
if len(writes) == 0 {
|
||||
t.Fatal("no frames written")
|
||||
}
|
||||
var arr []any
|
||||
if err := json.Unmarshal(writes[len(writes)-1], &arr); err != nil {
|
||||
t.Fatalf("decode frame: %v", err)
|
||||
}
|
||||
name, _ := arr[1].(string)
|
||||
payload, _ := arr[0].(map[string]any)
|
||||
return name, payload
|
||||
}
|
||||
|
||||
func TestSendConcurrentWithClose(t *testing.T) {
|
||||
// The core safety property of the rewrite: sending to a client that is closing
|
||||
// must never panic or race, only drop. Run under -race.
|
||||
for trial := 0; trial < 30; trial++ {
|
||||
c, _ := newTestClient("x")
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 100; j++ {
|
||||
c.Send(map[string]any{"j": j})
|
||||
}
|
||||
}()
|
||||
}
|
||||
go c.Close()
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendAfterCloseDoesNotPanic(t *testing.T) {
|
||||
c, _ := newTestClient("x")
|
||||
c.Close()
|
||||
waitFor(t, func() bool {
|
||||
select {
|
||||
case <-c.Done():
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
// Should return promptly and not panic.
|
||||
c.Send(map[string]any{"ignored": true})
|
||||
}
|
||||
|
||||
func TestRoomBroadcastDelivers(t *testing.T) {
|
||||
hub := NewHub()
|
||||
room := NewRoom(hub)
|
||||
room.OwnerID = "o"
|
||||
room.NotifyActionJoined = false // keep frame counts clean
|
||||
room.Publish()
|
||||
|
||||
a, fa := newTestClient("a")
|
||||
b, fb := newTestClient("b")
|
||||
hub.addClient(a)
|
||||
hub.addClient(b)
|
||||
room.Join(a)
|
||||
room.Join(b)
|
||||
|
||||
room.Broadcast("pack/room", map[string]any{"hello": float64(1)}, "", nil)
|
||||
|
||||
waitFor(t, func() bool { return fa.WriteCount() >= 1 && fb.WriteCount() >= 1 })
|
||||
|
||||
name, payload := lastSignal(t, fa)
|
||||
if name != "pack/room" {
|
||||
t.Fatalf("signal name = %q, want pack/room", name)
|
||||
}
|
||||
if payload["hello"] != float64(1) {
|
||||
t.Fatalf("payload = %v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomBroadcastRespectsExceptAndFilter(t *testing.T) {
|
||||
hub := NewHub()
|
||||
room := NewRoom(hub)
|
||||
room.OwnerID = "o"
|
||||
room.NotifyActionJoined = false
|
||||
room.Publish()
|
||||
|
||||
a, fa := newTestClient("a")
|
||||
b, fb := newTestClient("b")
|
||||
hub.addClient(a)
|
||||
hub.addClient(b)
|
||||
room.Join(a)
|
||||
room.Join(b)
|
||||
|
||||
// b opts out of peer-info notifications; the filter must skip it.
|
||||
b.SetStore(flagNotifyPairInfo, false)
|
||||
|
||||
room.Broadcast("x", map[string]any{"n": float64(1)}, "", (*Client).PeerInfoNotifiable)
|
||||
waitFor(t, func() bool { return fa.WriteCount() >= 1 })
|
||||
time.Sleep(20 * time.Millisecond) // give a wrong delivery a chance to show up
|
||||
|
||||
if fb.WriteCount() != 0 {
|
||||
t.Fatalf("filtered-out client received %d frames", fb.WriteCount())
|
||||
}
|
||||
|
||||
// exceptID must also be honoured.
|
||||
room.Broadcast("y", map[string]any{}, a.ID, nil)
|
||||
waitFor(t, func() bool { return fb.WriteCount() >= 1 })
|
||||
if name, _ := lastSignal(t, fa); name == "y" {
|
||||
t.Fatal("exceptID client should not have received the second broadcast")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomEmptyTriggersDown(t *testing.T) {
|
||||
hub := NewHub()
|
||||
room := NewRoom(hub)
|
||||
room.OwnerID = "a"
|
||||
room.Publish()
|
||||
|
||||
a, _ := newTestClient("a")
|
||||
hub.addClient(a)
|
||||
room.Join(a)
|
||||
|
||||
room.Eject(a)
|
||||
|
||||
if _, ok := hub.Room(room.ID); ok {
|
||||
t.Fatal("room should be removed from hub when it empties")
|
||||
}
|
||||
if a.InRoom(room.ID) {
|
||||
t.Fatal("client should no longer reference a downed room")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaveWhileSendRace is the #22 regression: broadcasting to a room while
|
||||
// members concurrently leave and rejoin. Under -race this catches any unguarded
|
||||
// access to shared room/peer state — the exact failure the Node engine had.
|
||||
func TestLeaveWhileSendRace(t *testing.T) {
|
||||
hub := NewHub()
|
||||
room := NewRoom(hub)
|
||||
room.OwnerID = "owner"
|
||||
room.Publish()
|
||||
|
||||
const n = 30
|
||||
clients := make([]*Client, n)
|
||||
for i := 0; i < n; i++ {
|
||||
c, _ := newTestClient(fmt.Sprintf("c%d", i))
|
||||
clients[i] = c
|
||||
hub.addClient(c)
|
||||
room.Join(c)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Broadcasters hammer the room.
|
||||
for g := 0; g < 4; g++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 300; j++ {
|
||||
room.Broadcast("pack/room", map[string]any{"j": j}, "", nil)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Churn: members leave and rejoin repeatedly while broadcasts are in flight.
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(c *Client) {
|
||||
defer wg.Done()
|
||||
for k := 0; k < 100; k++ {
|
||||
room.Eject(c)
|
||||
room.Join(c)
|
||||
}
|
||||
}(clients[i])
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
for _, c := range clients {
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubRegistryConcurrency(t *testing.T) {
|
||||
hub := NewHub()
|
||||
hub.Register("noop", func(c *Client, m protocol.Message) any { return success() })
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < 8; g++ {
|
||||
wg.Add(1)
|
||||
go func(g int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 200; i++ {
|
||||
id := fmt.Sprintf("g%d-%d", g, i)
|
||||
c := NewClient(testutil.NewFakeConn(), id)
|
||||
hub.addClient(c)
|
||||
_, _ = hub.Client(id)
|
||||
_ = hub.Clients()
|
||||
_ = hub.ClientCount()
|
||||
hub.removeClient(id)
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// success mirrors the services helper so the hub test stays self-contained.
|
||||
func success() map[string]any { return map[string]any{"status": "success"} }
|
||||
|
||||
func TestServerHandleRepliesToRequest(t *testing.T) {
|
||||
hub := NewHub()
|
||||
hub.Register("ping", func(c *Client, m protocol.Message) any {
|
||||
return map[string]any{"pong": true}
|
||||
})
|
||||
srv := NewServer(hub)
|
||||
|
||||
fc := testutil.NewFakeConn()
|
||||
go srv.handle(fc)
|
||||
|
||||
fc.Push([]byte(`[{"type":"ping"}, 5, "R"]`))
|
||||
waitFor(t, func() bool { return fc.WriteCount() >= 1 })
|
||||
|
||||
writes := fc.Writes()
|
||||
var arr []any
|
||||
if err := json.Unmarshal(writes[len(writes)-1], &arr); err != nil {
|
||||
t.Fatalf("decode reply: %v", err)
|
||||
}
|
||||
// Expect [ {"pong":true}, 5, "E" ].
|
||||
if len(arr) != 3 {
|
||||
t.Fatalf("reply arity = %d, want 3", len(arr))
|
||||
}
|
||||
if arr[1] != float64(5) {
|
||||
t.Fatalf("reply id = %v, want 5", arr[1])
|
||||
}
|
||||
if arr[2] != protocol.FlagEnd {
|
||||
t.Fatalf("reply flag = %v, want E", arr[2])
|
||||
}
|
||||
fc.Close()
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// Client is a minimal WSTS speaker used by the load tester. It mirrors the
|
||||
// frontend SDK's framing: requests carry a numeric id and a trailing "R", and the
|
||||
// engine replies with [payload, id, "E"]; server-initiated messages arrive as
|
||||
// [payload, name].
|
||||
type Client struct {
|
||||
ID string // assigned by the server's "id" signal
|
||||
|
||||
conn *websocket.Conn
|
||||
counter int64
|
||||
|
||||
mu sync.Mutex
|
||||
pending map[int64]chan json.RawMessage
|
||||
|
||||
sigMu sync.Mutex
|
||||
signals map[string]func(json.RawMessage)
|
||||
|
||||
writeMu sync.Mutex // gorilla allows only one concurrent writer
|
||||
|
||||
closed atomic.Bool
|
||||
idReady chan struct{}
|
||||
}
|
||||
|
||||
// Dial connects to the engine at url and starts the read loop.
|
||||
func Dial(url string) (*Client, error) {
|
||||
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &Client{
|
||||
conn: conn,
|
||||
pending: make(map[int64]chan json.RawMessage),
|
||||
signals: make(map[string]func(json.RawMessage)),
|
||||
idReady: make(chan struct{}),
|
||||
}
|
||||
// Capture our socket id from the YourID signal.
|
||||
c.OnSignal("id", func(raw json.RawMessage) {
|
||||
var p struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
if json.Unmarshal(raw, &p) == nil && p.Value != "" {
|
||||
c.ID = p.Value
|
||||
close(c.idReady)
|
||||
}
|
||||
})
|
||||
go c.readLoop()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// WaitID blocks until the server has told us our socket id (or the timeout fires).
|
||||
func (c *Client) WaitID(timeout time.Duration) error {
|
||||
select {
|
||||
case <-c.idReady:
|
||||
return nil
|
||||
case <-time.After(timeout):
|
||||
return fmt.Errorf("timed out waiting for socket id")
|
||||
}
|
||||
}
|
||||
|
||||
// OnSignal registers a handler for a server-initiated signal name.
|
||||
func (c *Client) OnSignal(name string, fn func(json.RawMessage)) {
|
||||
c.sigMu.Lock()
|
||||
c.signals[name] = fn
|
||||
c.sigMu.Unlock()
|
||||
}
|
||||
|
||||
// Request sends a request and waits for the engine's reply, returning the round
|
||||
// trip time. The payload must be a JSON object carrying a "type".
|
||||
func (c *Client) Request(payload any, timeout time.Duration) (json.RawMessage, time.Duration, error) {
|
||||
id := atomic.AddInt64(&c.counter, 1)
|
||||
ch := make(chan json.RawMessage, 1)
|
||||
c.mu.Lock()
|
||||
c.pending[id] = ch
|
||||
c.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
delete(c.pending, id)
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
if err := c.write([]any{payload, id, "R"}); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
select {
|
||||
case resp := <-ch:
|
||||
return resp, time.Since(start), nil
|
||||
case <-time.After(timeout):
|
||||
return nil, 0, fmt.Errorf("request timed out")
|
||||
}
|
||||
}
|
||||
|
||||
// SendOnly sends a fire-and-forget message (the "R" string id path) for which the
|
||||
// engine produces no reply.
|
||||
func (c *Client) SendOnly(payload any) error {
|
||||
return c.write([]any{payload, "R"})
|
||||
}
|
||||
|
||||
func (c *Client) write(v any) error {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
return c.conn.WriteMessage(websocket.TextMessage, b)
|
||||
}
|
||||
|
||||
// Close shuts the connection down.
|
||||
func (c *Client) Close() {
|
||||
if c.closed.Swap(true) {
|
||||
return
|
||||
}
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
|
||||
func (c *Client) readLoop() {
|
||||
for {
|
||||
_, data, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var arr []json.RawMessage
|
||||
if json.Unmarshal(data, &arr) != nil || len(arr) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
// arr[1] is either a numeric id (a reply) or a string (a signal).
|
||||
var num int64
|
||||
if json.Unmarshal(arr[1], &num) == nil && looksNumeric(arr[1]) {
|
||||
c.deliverReply(num, arr[0])
|
||||
continue
|
||||
}
|
||||
var name string
|
||||
if json.Unmarshal(arr[1], &name) == nil {
|
||||
c.deliverSignal(name, arr[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) deliverReply(id int64, payload json.RawMessage) {
|
||||
c.mu.Lock()
|
||||
ch := c.pending[id]
|
||||
c.mu.Unlock()
|
||||
if ch != nil {
|
||||
select {
|
||||
case ch <- payload:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) deliverSignal(name string, payload json.RawMessage) {
|
||||
c.sigMu.Lock()
|
||||
fn := c.signals[name]
|
||||
c.sigMu.Unlock()
|
||||
if fn != nil {
|
||||
fn(payload)
|
||||
}
|
||||
}
|
||||
|
||||
// looksNumeric reports whether a raw JSON token is a number (so "5" is a reply id
|
||||
// but "\"room/joined\"" is a signal name).
|
||||
func looksNumeric(raw json.RawMessage) bool {
|
||||
if len(raw) == 0 {
|
||||
return false
|
||||
}
|
||||
c := raw[0]
|
||||
return c == '-' || (c >= '0' && c <= '9')
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
module git.saqut.com/saqut/mwse-loadtest
|
||||
|
||||
go 1.26.3
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
// Command mwse-loadtest drives the MWSE engine with many concurrent WebSocket
|
||||
// clients to smoke-test correctness and measure throughput/latency. It is a
|
||||
// separate Go module so it can be built and run independently of the engine, and
|
||||
// reused as a benchmark harness.
|
||||
//
|
||||
// Run the engine, then:
|
||||
//
|
||||
// go run . # default: ping mode, 50 clients, 10s
|
||||
// go run . -mode relay -clients 200 -dur 15s
|
||||
// go run . -addr ws://localhost:7707/ -clients 500 -mode ping
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", "ws://localhost:7707/", "engine WebSocket URL")
|
||||
clients := flag.Int("clients", 50, "number of concurrent clients")
|
||||
dur := flag.Duration("dur", 10*time.Second, "test duration")
|
||||
mode := flag.String("mode", "ping", "scenario: ping | relay")
|
||||
flag.Parse()
|
||||
|
||||
log.Printf("connecting %d clients to %s ...", *clients, *addr)
|
||||
conns := dialAll(*addr, *clients)
|
||||
defer func() {
|
||||
for _, c := range conns {
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
log.Printf("connected %d clients", len(conns))
|
||||
|
||||
switch *mode {
|
||||
case "ping":
|
||||
runPing(conns, *dur)
|
||||
case "relay":
|
||||
runRelay(conns, *dur)
|
||||
default:
|
||||
log.Fatalf("unknown mode %q (want ping or relay)", *mode)
|
||||
}
|
||||
}
|
||||
|
||||
// dialAll connects n clients and waits for each to receive its socket id.
|
||||
func dialAll(addr string, n int) []*Client {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
conns []*Client
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
c, err := Dial(addr)
|
||||
if err != nil {
|
||||
log.Printf("dial failed: %v", err)
|
||||
return
|
||||
}
|
||||
if err := c.WaitID(5 * time.Second); err != nil {
|
||||
log.Printf("no id: %v", err)
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
conns = append(conns, c)
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return conns
|
||||
}
|
||||
|
||||
// runPing has every client issue back-to-back requests (my/socketid) for the
|
||||
// duration, measuring round-trip latency and total throughput.
|
||||
func runPing(conns []*Client, dur time.Duration) {
|
||||
var lat latency
|
||||
var errors int64
|
||||
deadline := time.Now().Add(dur)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, c := range conns {
|
||||
wg.Add(1)
|
||||
go func(c *Client) {
|
||||
defer wg.Done()
|
||||
for time.Now().Before(deadline) {
|
||||
_, rtt, err := c.Request(map[string]any{"type": "my/socketid"}, 5*time.Second)
|
||||
if err != nil {
|
||||
atomic.AddInt64(&errors, 1)
|
||||
return
|
||||
}
|
||||
lat.record(rtt)
|
||||
}
|
||||
}(c)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
total := lat.count()
|
||||
p50, p90, p99, max := lat.percentiles()
|
||||
fmt.Println("\n=== ping results ===")
|
||||
fmt.Printf("clients : %d\n", len(conns))
|
||||
fmt.Printf("requests : %d\n", total)
|
||||
fmt.Printf("errors : %d\n", errors)
|
||||
fmt.Printf("throughput : %.0f req/s\n", float64(total)/dur.Seconds())
|
||||
fmt.Printf("latency p50 : %s\n", p50)
|
||||
fmt.Printf("latency p90 : %s\n", p90)
|
||||
fmt.Printf("latency p99 : %s\n", p99)
|
||||
fmt.Printf("latency max : %s\n", max)
|
||||
}
|
||||
|
||||
// runRelay pairs clients (2i <-> 2i+1) and has each send fire-and-forget packs to
|
||||
// its partner, counting deliveries to measure relay fanout throughput.
|
||||
func runRelay(conns []*Client, dur time.Duration) {
|
||||
if len(conns) < 2 {
|
||||
log.Fatal("relay mode needs at least 2 clients")
|
||||
}
|
||||
|
||||
var delivered int64
|
||||
for _, c := range conns {
|
||||
c.OnSignal("pack", func(json.RawMessage) { atomic.AddInt64(&delivered, 1) })
|
||||
}
|
||||
|
||||
var sent int64
|
||||
deadline := time.Now().Add(dur)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i+1 < len(conns); i += 2 {
|
||||
a, b := conns[i], conns[i+1]
|
||||
for _, pair := range [][2]*Client{{a, b}, {b, a}} {
|
||||
src, dst := pair[0], pair[1]
|
||||
wg.Add(1)
|
||||
go func(src, dst *Client) {
|
||||
defer wg.Done()
|
||||
for time.Now().Before(deadline) {
|
||||
err := src.SendOnly(map[string]any{
|
||||
"type": "pack/to",
|
||||
"to": dst.ID,
|
||||
"pack": map[string]any{"t": time.Now().UnixNano()},
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
atomic.AddInt64(&sent, 1)
|
||||
}
|
||||
}(src, dst)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Allow a moment for the last in-flight relays to arrive.
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
|
||||
fmt.Println("\n=== relay results ===")
|
||||
fmt.Printf("clients : %d\n", len(conns))
|
||||
fmt.Printf("packs sent : %d\n", sent)
|
||||
fmt.Printf("packs recvd : %d\n", atomic.LoadInt64(&delivered))
|
||||
fmt.Printf("send rate : %.0f msg/s\n", float64(sent)/dur.Seconds())
|
||||
fmt.Printf("recv rate : %.0f msg/s\n", float64(delivered)/dur.Seconds())
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// latency collects request durations and reports percentiles. It is safe for
|
||||
// concurrent recording.
|
||||
type latency struct {
|
||||
mu sync.Mutex
|
||||
samples []time.Duration
|
||||
}
|
||||
|
||||
func (l *latency) record(d time.Duration) {
|
||||
l.mu.Lock()
|
||||
l.samples = append(l.samples, d)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *latency) count() int {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return len(l.samples)
|
||||
}
|
||||
|
||||
// percentiles returns p50/p90/p99 and the max. Returns zeros when empty.
|
||||
func (l *latency) percentiles() (p50, p90, p99, max time.Duration) {
|
||||
l.mu.Lock()
|
||||
s := append([]time.Duration(nil), l.samples...)
|
||||
l.mu.Unlock()
|
||||
if len(s) == 0 {
|
||||
return 0, 0, 0, 0
|
||||
}
|
||||
sort.Slice(s, func(i, j int) bool { return s[i] < s[j] })
|
||||
at := func(q float64) time.Duration {
|
||||
idx := int(q * float64(len(s)-1))
|
||||
return s[idx]
|
||||
}
|
||||
return at(0.50), at(0.90), at(0.99), s[len(s)-1]
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
// Command mwse is the MWSE engine: a WebSocket relay that virtualizes connected
|
||||
// peers so they can exchange data through rooms, pairings and data tunnels without
|
||||
// knowing one another's real identity. It is the Go rewrite of the original
|
||||
// Node.js engine; the on-the-wire SDK contract is unchanged.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"git.saqut.com/saqut/mwse/internal/config"
|
||||
"git.saqut.com/saqut/mwse/internal/httpserver"
|
||||
"git.saqut.com/saqut/mwse/internal/services"
|
||||
"git.saqut.com/saqut/mwse/internal/ws"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
hub := ws.NewHub()
|
||||
services.Register(hub)
|
||||
|
||||
srv := httpserver.New(hub, cfg)
|
||||
|
||||
// Run the listener in the background so main can wait for a shutdown signal.
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
log.Printf("MWSE engine listening on %s", cfg.Addr())
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
serverErr <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for SIGINT/SIGTERM or a fatal listener error.
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case err := <-serverErr:
|
||||
log.Fatalf("MWSE engine failed: %v", err)
|
||||
case sig := <-stop:
|
||||
log.Printf("received %s, shutting down gracefully", sig)
|
||||
}
|
||||
|
||||
// Graceful shutdown: stop accepting new HTTP work, then close every live
|
||||
// WebSocket connection so peers receive a clean close frame.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Printf("http shutdown error: %v", err)
|
||||
}
|
||||
hub.CloseAll()
|
||||
|
||||
log.Printf("MWSE engine stopped")
|
||||
}
|
||||
Loading…
Reference in New Issue