Compare commits
24 Commits
stable
...
go-rewrite
| Author | SHA1 | Date |
|---|---|---|
|
|
5ebd111af0 | |
|
|
d468c95adf | |
|
|
f5565f5df0 | |
|
|
c1d1ddf383 | |
|
|
0c654ae4c8 | |
|
|
d9598ba15f | |
|
|
3736d78dfe | |
|
|
3bba5af340 | |
|
|
f2d318d639 | |
|
|
66158b1f74 | |
|
|
764644176c | |
|
|
0d21d8c8b3 | |
|
|
777f422873 | |
|
|
75d5999b4a | |
|
|
06ca31eecb | |
|
|
28abefaaa9 | |
|
|
18269059cc | |
|
|
441093bad6 | |
|
|
945b7621a4 | |
|
|
63680fac19 | |
|
|
91ebbeffb2 | |
|
|
f079ef5325 | |
|
|
c058feb22d | |
|
|
835f0b5f2e |
|
|
@ -0,0 +1,18 @@
|
||||||
|
Bugünkü TEK iş: MWSE Node.js engine'ini Go'ya taşımak (milestone 0.1.0 çekirdeği). Hiçbir issue KAPATMA, etiketleme veya yorumlama — sadece kodu yaz. Issue'ları yalnızca SPEC olarak oku: `./tools/gitea issue view 21` (… 22, 23, 24, 25, 26).
|
||||||
|
|
||||||
|
Önce `CLAUDE.md` ve `todo.md`'yi oku (bağlam + KATI teknik kısıtlar).
|
||||||
|
|
||||||
|
Kapsam (SADECE engine; frontend/WebRTC/studio'ya GİRME):
|
||||||
|
1. Go WS sunucu iskeleti + bağlantı yaşam döngüsü (#21).
|
||||||
|
2. ÇEKİRDEK: concurrency modeli (#22) — room/peer state için owner-goroutine (actor) + channel, ya da `sync.RWMutex`. Node'daki "biri odadan ayrılırken başka goroutine o peer'e yazınca race" sorununu çöz. Tasarım gerekçeni `PORT-PROGRESS.md`'ye yaz.
|
||||||
|
3. WSTS protokolünü Go'da yeniden uygula (#23) — SDK giriş/çıkış sözleşmesi SABİT kalmalı.
|
||||||
|
4. MessageRouter + Services (Auth, Room, Session, IPPressure, DataTransfer, YourID) portu (#24).
|
||||||
|
5. Config + HTTP + graceful shutdown (#25).
|
||||||
|
6. `go test -race` ile testler (#26) — özellikle eşzamanlı join/leave/broadcast ve "ayrılırken-yazma" regression'ı yeşil olmalı.
|
||||||
|
|
||||||
|
Kurallar:
|
||||||
|
- `go-rewrite` branch'inde çalış (yoksa oluştur), commit at. `stable`'a dokunma, deploy etme, push'u bana bırak.
|
||||||
|
- İzin/şifre sorma. git.saqut.com okuman gerekirse `./tools/gitea` kullan — ama hiçbir issue'yu KAPATMA/değiştirme.
|
||||||
|
- Soru sorup bekleme; kararsız kalırsan en makulü seç, gerekçeyi yaz, devam et.
|
||||||
|
- Bittiğinde `PORT-PROGRESS.md`'ye yaz: ne yapıldı, ne kaldı, kritik kararlar (özellikle concurrency). Yarın ben inceleyeceğim.
|
||||||
|
- Bu tek işi bitir, sonra DUR.
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
Sen MWSE projesini bitiren otonom bir geliştirici ajansın. İLK İŞ: `CLAUDE.md` ve `todo.md` dosyalarını oku ve harfiyen uygula.
|
||||||
|
|
||||||
|
Kurallar:
|
||||||
|
1. `go-rewrite` branch'inde çalış (yoksa: git checkout -b go-rewrite).
|
||||||
|
2. git.saqut.com için SADECE `./tools/gitea` CLI'ını kullan; kimlik .gitea-auth.json'dan gelir — şifre/izin/erişim SORMA, doğrudan kullan.
|
||||||
|
3. İş döngüsü: `./tools/gitea issue list --state open` → en düşük milestone'daki (önce 0.1.0) issue'yu seç → `./tools/gitea issue view <num>` ile oku → tamamen uygula → test yaz ve çalıştır (engine için `go test -race` ZORUNLU) → testler yeşilse commit at → `./tools/gitea issue close <num> --comment "tamamlandı: ..."` ile kapat → sıradakine geç.
|
||||||
|
4. ASLA bana soru sorup bekleme. Kararsız kalırsan en makul seçeneği uygula, gerekçeyi `decisions.md`'ye yaz, DEVAM ET.
|
||||||
|
5. CLAUDE.md'de "insan-onayına bırakılacak" denenler (#22 concurrency, akış proxy/relay, sanal-IP algoritması, stable'a merge / deploy): uygula ama issue'yu KAPATMA; `REVIEW.md`'ye "incelenmeli" diye yaz, sıradakine geç.
|
||||||
|
6. Bir issue'da çözülemez engel olursa `BLOCKERS.md`'ye yaz, o issue'yu atla, devam et.
|
||||||
|
7. Teknik kısıt (KATI): engine = Go (goroutine/channel/mutex). Frontend = saf vanilla ES module JS, React YOK, bundler (Parcel/Webpack/Vite) YOK. SDK giriş/çıkış sözleşmesi sabit.
|
||||||
|
|
||||||
|
Tüm açık issue'lar bitene kadar DURMA. Başla.
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"host": "https://git.saqut.com",
|
||||||
|
"user": "saqut",
|
||||||
|
"password": "BURAYA_SIFRE_VEYA_TOKEN",
|
||||||
|
"_not": "password yerine daha güvenli 'token' alanı da kullanılabilir: { \"token\": \"<access-token>\" }. Gerçek dosya .gitea-auth.json adıyla kopyalanır ve .gitignore'dadır.",
|
||||||
|
"repo": "saqut/MWSE"
|
||||||
|
}
|
||||||
|
|
@ -1,134 +1,31 @@
|
||||||
# ---> Node
|
# Gitea CLI credentials — never commit
|
||||||
# Logs
|
.gitea-auth.json
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
# Go build outputs
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
/mwse
|
||||||
|
/mwse-engine
|
||||||
|
*.out
|
||||||
|
*.test
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
loadtest/mwse-loadtest
|
||||||
|
|
||||||
# Runtime data
|
# node_modules stays on disk (gitignored) so npm tools still work if needed,
|
||||||
pids
|
# but nothing in the repo should require them anymore.
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
|
||||||
lib-cov
|
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
|
||||||
coverage
|
|
||||||
*.lcov
|
|
||||||
|
|
||||||
# nyc test coverage
|
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
|
||||||
.grunt
|
|
||||||
|
|
||||||
# Bower dependency directory (https://bower.io/)
|
|
||||||
bower_components
|
|
||||||
|
|
||||||
# node-waf configuration
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
|
||||||
build/Release
|
|
||||||
|
|
||||||
# Dependency directories
|
|
||||||
node_modules/
|
node_modules/
|
||||||
jspm_packages/
|
package-lock.json
|
||||||
|
.parcel-cache/
|
||||||
# Snowpack dependency directory (https://snowpack.dev/)
|
|
||||||
web_modules/
|
|
||||||
|
|
||||||
# TypeScript cache
|
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Optional npm cache directory
|
# Environment / secrets
|
||||||
.npm
|
|
||||||
|
|
||||||
# Optional eslint cache
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Optional stylelint cache
|
|
||||||
.stylelintcache
|
|
||||||
|
|
||||||
# Microbundle cache
|
|
||||||
.rpt2_cache/
|
|
||||||
.rts2_cache_cjs/
|
|
||||||
.rts2_cache_es/
|
|
||||||
.rts2_cache_umd/
|
|
||||||
|
|
||||||
# Optional REPL history
|
|
||||||
.node_repl_history
|
|
||||||
|
|
||||||
# Output of 'npm pack'
|
|
||||||
*.tgz
|
|
||||||
|
|
||||||
# Yarn Integrity file
|
|
||||||
.yarn-integrity
|
|
||||||
|
|
||||||
# dotenv environment variable files
|
|
||||||
.env
|
.env
|
||||||
.env.development.local
|
.env.*
|
||||||
.env.test.local
|
!.env.example
|
||||||
.env.production.local
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
# Editor / OS
|
||||||
.cache
|
.DS_Store
|
||||||
.parcel-cache
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
|
||||||
# Next.js build output
|
.well-known/
|
||||||
.next
|
|
||||||
out
|
|
||||||
|
|
||||||
# Nuxt.js build / generate output
|
|
||||||
.nuxt
|
|
||||||
dist
|
|
||||||
|
|
||||||
# Gatsby files
|
|
||||||
.cache/
|
|
||||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
|
||||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
|
||||||
# public
|
|
||||||
|
|
||||||
# vuepress build output
|
|
||||||
.vuepress/dist
|
|
||||||
|
|
||||||
# vuepress v2.x temp and cache directory
|
|
||||||
.temp
|
|
||||||
.cache
|
|
||||||
|
|
||||||
# Docusaurus cache and generated files
|
|
||||||
.docusaurus
|
|
||||||
|
|
||||||
# Serverless directories
|
|
||||||
.serverless/
|
|
||||||
|
|
||||||
# FuseBox cache
|
|
||||||
.fusebox/
|
|
||||||
|
|
||||||
# DynamoDB Local files
|
|
||||||
.dynamodb/
|
|
||||||
|
|
||||||
# TernJS port file
|
|
||||||
.tern-port
|
|
||||||
|
|
||||||
# Stores VSCode versions used for testing VSCode extensions
|
|
||||||
.vscode-test
|
|
||||||
|
|
||||||
# yarn v2
|
|
||||||
.yarn/cache
|
|
||||||
.yarn/unplugged
|
|
||||||
.yarn/build-state.yml
|
|
||||||
.yarn/install-state.gz
|
|
||||||
.pnp.*
|
|
||||||
|
|
||||||
.well-known
|
|
||||||
.well-known/*
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
# CLAUDE.md — Otonom Ajan Operating Contract (MWSE)
|
||||||
|
|
||||||
|
Bu dosya, MWSE issue'larını otonom (durmadan) bitirmek için çalışan AI ajanına aittir. **Önce `todo.md`'yi oku** (tam bağlam + yol haritası + studio UI vizyonu). Sonra bu kurallarla çalış.
|
||||||
|
|
||||||
|
## Yetki (önceden verildi — SORMA)
|
||||||
|
|
||||||
|
Aşağıdakiler için **izin isteme, onay bekleme**:
|
||||||
|
- Bu repo içinde dosya okuma/yazma/oluşturma/silme.
|
||||||
|
- Shell, build, test, `go`, `npm` komutları çalıştırma.
|
||||||
|
- `git.saqut.com/saqut/MWSE` API'sini kullanma (issue okuma/kapatma/yorum, commit).
|
||||||
|
- Yeni branch açma, commit atma.
|
||||||
|
|
||||||
|
## Gitea ile etkileşim — `tools/gitea` CLI KULLAN
|
||||||
|
|
||||||
|
git.saqut.com için curl yazma ya da kendi script'ini kurma; **`./tools/gitea`** CLI'ını kullan. Cloudflare-safe (tarayıcı UA) ve kimlik `.gitea-auth.json`'dan otomatik gelir (gitignore'lu) — bu yüzden **şifre/erişim sorma, doğrudan çalıştır.**
|
||||||
|
|
||||||
|
- Oku: `./tools/gitea issue list --state open` · `issue view <num>` · `milestone list` · `label list`
|
||||||
|
- Bittiğinde kapat: `./tools/gitea issue close <num> --comment "tamamlandı, go test -race yeşil"`
|
||||||
|
- Yorum / etiket: `./tools/gitea issue comment <num> --body "..."` · `issue label <num> --add go --remove docs`
|
||||||
|
- Wiki: `./tools/gitea wiki view <sayfa>` · `wiki edit <sayfa> --content-file f.md`
|
||||||
|
- PR (insan-onayı gereken işler için): `./tools/gitea pr create --title "..." --head go-rewrite --base stable --body-file PR.md` · `pr list`
|
||||||
|
- Tüm komutlar: `./tools/gitea --help`
|
||||||
|
|
||||||
|
**Kod göndermek** API değil, normal `git`: `git checkout -b go-rewrite` → commit → `git push origin go-rewrite`. (origin ayarlı, default branch `stable`.) Auth sorarsa token'lı remote / credential helper kur, ama `.gitea-auth.json`'ı ASLA commit etme.
|
||||||
|
|
||||||
|
## Durma & soru protokolü (EN ÖNEMLİ)
|
||||||
|
|
||||||
|
- **Asla kullanıcıya soru sorup beklemeye geçme.** "Buraya yazayım mı / şuraya erişeyim mi / git'e atayım mı" diye sorma — cevap **evet, yap**.
|
||||||
|
- Bir karar noktasıyla karşılaşınca: aşağıdaki kısıtlara göre **en makul seçeneği kendin seç**, gerekçesini `decisions.md`'ye yaz, **DEVAM ET**.
|
||||||
|
- Çözemediğin sert bir engel olursa: `BLOCKERS.md`'ye yaz, o issue'yu atla, **bir sonrakine geç.** Durma.
|
||||||
|
- Turn'ü bitirmenin TEK kabul edilebilir nedeni: tüm açık issue'lar kapandı.
|
||||||
|
|
||||||
|
## İş döngüsü
|
||||||
|
|
||||||
|
1. `git.saqut.com/saqut/MWSE` açık issue'larından **en düşük milestone'daki** (önce 0.1.0) başlanmamış olanı seç.
|
||||||
|
2. Tamamen uygula.
|
||||||
|
3. Testlerini yaz ve çalıştır. Engine için **`go test -race` ZORUNLU.**
|
||||||
|
4. Yalnızca testler yeşilse: commit at + issue'yu API ile kapat.
|
||||||
|
5. Bir sonraki issue'ya geç. Tekrar.
|
||||||
|
|
||||||
|
## Bitti tanımı (global)
|
||||||
|
|
||||||
|
- Kod derleniyor, testler geçiyor, issue'nun kabul kriteri karşılanıyor.
|
||||||
|
- **Testi kırık ya da `skip`'li hiçbir şey "bitti" sayılmaz.** Issue'yu öyle kapatma.
|
||||||
|
|
||||||
|
## Teknik kısıtlar (KATI — ihlal etme)
|
||||||
|
|
||||||
|
- **Engine = Go.** Concurrency: goroutine + channel + `sync.RWMutex`, ya da room/peer başına owner-goroutine (actor). Node'daki "ayrılırken-yazma" race'i bir daha oluşmamalı.
|
||||||
|
- **Frontend = saf vanilla ES module JavaScript.** Yüzlerce dosya birbirini native `import` ile çağırsın. **React YOK. Parcel/Webpack/Vite YOK (bundler yok).** jQuery/moment gibi bağımlılıklar serbest. TypeScript opsiyonel ve kaldırılabilir — şüphedeysen düz JS tercih et.
|
||||||
|
- **SDK giriş/çıkış sözleşmesi DONDURULDU.** Public API'yi değiştirme; sadece sunucu içi yapı değişir.
|
||||||
|
|
||||||
|
## İnsan-onayına bırakılacaklar (uygula ama KAPATMA, deploy ETME)
|
||||||
|
|
||||||
|
Şunları branch'te uygula, `REVIEW.md`'ye "incelenmeli" diye yaz, kapatma ve canlıya alma:
|
||||||
|
- **#22 concurrency modeli** (asıl kritik tasarım).
|
||||||
|
- Akış proxy/relay mimarisi, sanal-IP çakışma algoritması.
|
||||||
|
- `stable` branch'e merge ve `ws.saqut.com` deploy'una dokunan hiçbir şey.
|
||||||
|
|
||||||
|
## Güvenlik
|
||||||
|
|
||||||
|
- `go-rewrite` gibi **özel bir branch'te** çalış. `stable`'a doğrudan dokunma.
|
||||||
|
- Force-push yok, veri silme yok, prod deploy yok.
|
||||||
|
|
@ -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 ./...
|
||||||
|
```
|
||||||
229
README.md
229
README.md
|
|
@ -1,59 +1,214 @@
|
||||||
# MWSE — Micro WebSocket Engine
|
# MWSE — Micro Web Socket Engine
|
||||||
|
|
||||||
> **Durum: Go yeniden yazımı (0.1.0) başlıyor.** Mevcut Node.js engine `ws.saqut.com`'da canlı; çekirdek, concurrency'i doğru çözmek için Go'ya taşınıyor. Frontend SDK'nın giriş/çıkış sözleşmesi **değişmiyor**.
|
MWSE, kendisine bağlanan eşleri birbirleriyle eşleştirerek eşler arası veri
|
||||||
|
tünelleri oluşturan geniş ölçekli bir WebSocket mikroservis altyapısıdır.
|
||||||
|
|
||||||
MWSE, kendisine bağlanan eşleri (peer) birbirleriyle eşleştirip aralarında **düşük gecikmeli veri tünelleri** kuran bir gerçek-zamanlı iletişim motorudur. Sunucu cihazları sanallaştırır; eşler birbirlerinin IP'sini veya cihaz türünü bilmeden, çift yönlü serbestçe haberleşir. Üstünde oda yönetimi, peer pairing, sanal adresleme, dosya transferi ve WebRTC sinyalleşmesi gelir.
|
Servis; cihazları senkronize etmek, odalar oluşturmak, sohbet ve görüntülü
|
||||||
|
görüşme yazılımları için gerçek zamanlı altyapı sağlamak amacıyla kullanılır.
|
||||||
|
Sunucu cihazları sanallaştırdığı için eşler birbirlerinin gerçek IP adresini veya
|
||||||
|
cihaz bilgisini bilmeden düşük gecikmeli, çift yönlü iletişim kurabilir.
|
||||||
|
|
||||||
## Neden var?
|
## Durum (Go engine, v1.0.0)
|
||||||
|
|
||||||
Gerçek-zamanlı uygulama altyapısı bugün ya sadece transport (Socket.io) ya da pahalı SaaS (Twilio/LiveKit). MWSE'nin hedefi: **birkaç satırda** oda + mesaj + sesli/görüntülü görüşme kurabileceğin, kendi sunucunda çalışan açık bir motor.
|
Motor Node.js'ten **Go** ile yeniden yazıldı. Concurrency modeli goroutine +
|
||||||
|
`sync.RWMutex` + bağlantı başına tek-yazıcı (actor) deseni üzerine kuruludur;
|
||||||
|
Node.js'teki "leave-while-send" race condition ve EventPool promise takılması (#33)
|
||||||
|
giderildi. Yük testi: 150 bağlantı, ~210 k msg/s relay, RSS ~43 MB.
|
||||||
|
|
||||||
|
| Özellik | Durum |
|
||||||
|
|---|---|
|
||||||
|
| WebSocket bağlantı yaşam döngüsü | ✅ |
|
||||||
|
| Oda oluşturma / katılma / çıkma | ✅ |
|
||||||
|
| Eşleme (pair) sistemi | ✅ |
|
||||||
|
| Paket tünelleme (pack/to, pack/room) | ✅ |
|
||||||
|
| Veri senkronizasyonu (data/sync, sync/pool) | ✅ |
|
||||||
|
| Bildirim + suit yanıtı (notify/send, notify/reply) | ✅ |
|
||||||
|
| 3. parti sunucu köprüsü (bridge) | ✅ |
|
||||||
|
| Sanal IP / numara / kısa kod + alt ağ | ✅ |
|
||||||
|
| WebRTC kütüphanesi (perfect negotiation, çoklu track) | ✅ |
|
||||||
|
| Studio UI (Miller kolonlar) | ✅ |
|
||||||
|
| İkili çerçeveleme (binary framing) | ⏳ 2.5.0 |
|
||||||
|
| SRS entegrasyonu (binlerce izleyici) | ⏳ 2.0.0 |
|
||||||
|
|
||||||
|
## Kurulum ve çalıştırma
|
||||||
|
|
||||||
|
### Gereksinimler
|
||||||
|
|
||||||
|
- Go 1.22+
|
||||||
|
|
||||||
|
### Sunucuyu başlat
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go mod tidy
|
||||||
|
go run .
|
||||||
|
# Varsayılan: 0.0.0.0:7707
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ortam değişkenleri
|
||||||
|
|
||||||
|
| Değişken | Varsayılan | Açıklama |
|
||||||
|
|---|---|---|
|
||||||
|
| `MWSE_HOST` | `0.0.0.0` | Bind adresi |
|
||||||
|
| `MWSE_PORT` | `7707` | Dinleme portu |
|
||||||
|
| `MWSE_PUBLIC_DIR` | `./public` | Statik dosyalar (`/status.xml` vb.) |
|
||||||
|
| `MWSE_SDK_DIR` | `./sdk` | ES modül SDK (`/sdk/`) |
|
||||||
|
| `MWSE_OUTBOUND_BUFFER` | `1024` | Bağlantı başına gönderim kuyruğu |
|
||||||
|
| `MWSE_MAX_MESSAGE_SIZE` | `16777216` | Maksimum gelen frame boyutu (bayt) |
|
||||||
|
| `MWSE_PING_INTERVAL` | `10s` | Heartbeat ping aralığı |
|
||||||
|
| `MWSE_SHUTDOWN_TIMEOUT` | `10s` | Graceful shutdown bekleme süresi |
|
||||||
|
| `BRIDGE_APPROVE_URL` | — | Bağlantı onay URL'i (3. parti köprü) |
|
||||||
|
| `BRIDGE_TRIGGER_URL` | — | Suit yanıtı push URL'i |
|
||||||
|
| `BRIDGE_INBOX` | — | `1` ile inbox'ı etkinleştir |
|
||||||
|
|
||||||
|
### Testler
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -race ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend SDK entegrasyonu
|
||||||
|
|
||||||
|
SDK saf vanilla ES modülü olarak `/sdk/` endpoint'inden sunulur. Bundler gerekmez;
|
||||||
|
native `import` ile çalışır.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script type="module">
|
||||||
|
import MWSE from 'http://localhost:7707/sdk/index.js';
|
||||||
|
|
||||||
|
const mwse = new MWSE(); // endpoint: otomatik aynı sunucudan
|
||||||
|
|
||||||
|
mwse.on('scope', async () => {
|
||||||
|
console.log('Bağlandı:', mwse.me.socketId);
|
||||||
|
const room = mwse.room({ name: 'genel', joinType: 'free', ifexistsJoin: true });
|
||||||
|
await room.createRoom();
|
||||||
|
room.on('message', (pack, peer) => console.log(peer.socketId, ':', pack));
|
||||||
|
room.send({ text: 'merhaba!' });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebRTC (P2P ses/video/dosya)
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const mwse = new MWSE({ endpoint: "wss://ws.saqut.com/" });
|
import MWSE from '/sdk/index.js';
|
||||||
|
import { MediaSources } from '/sdk/webrtc/index.js';
|
||||||
|
|
||||||
mwse.scope(async () => {
|
const mwse = new MWSE();
|
||||||
const room = mwse.room({ name: "lobi", joinType: "free", ifexistsJoin: true });
|
|
||||||
await room.join();
|
mwse.me.on('accepted/pair', async peer => {
|
||||||
room.on("message", (peer, data) => console.log(peer.socketId, data));
|
const polite = mwse.me.socketId < peer.socketId;
|
||||||
room.broadcast("herkese merhaba");
|
peer.rtc.connect({ polite });
|
||||||
|
|
||||||
|
// Kamera + mikrofon
|
||||||
|
const stream = await MediaSources.cameraAndMic();
|
||||||
|
peer.rtc.addStream('cam', stream);
|
||||||
|
|
||||||
|
// Gelen video/ses
|
||||||
|
peer.rtc.on('track', (track, streams) => {
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.srcObject = streams[0];
|
||||||
|
video.autoplay = true;
|
||||||
|
document.body.appendChild(video);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dosya gönderme
|
||||||
|
await peer.rtc.sendFile(file);
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
- **~20 satır** → grup mesajlaşma
|
### Studio UI
|
||||||
- **~100 satır** → sesli görüşme
|
|
||||||
- **~500 satır** → görüntülü görüşme
|
|
||||||
|
|
||||||
Tam örnekler için → [Wiki](https://git.saqut.com/saqut/MWSE/wiki/Home).
|
```js
|
||||||
|
import MWSE from '/sdk/index.js';
|
||||||
|
import Studio from '/sdk/studio/index.js';
|
||||||
|
|
||||||
## Mimari (özet)
|
const mwse = new MWSE();
|
||||||
|
const studio = new Studio(mwse, '#app');
|
||||||
```
|
mwse.on('scope', () => studio.mount());
|
||||||
[Client SDK] ⇄ WSTS protokolü ⇄ [MWSE Engine]
|
|
||||||
(frontend/) (paket kimliklendirme, (Source/: WebSocket, MessageRouter,
|
|
||||||
request/response/stream) Services/{Auth, Room, Session,
|
|
||||||
IPPressure, DataTransfer, YourID})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- **WSTS protokolü:** WebSocket üzerine kurulu, giden/gelen paketleri kimliklendiren ve "kimden/kime" yönlendiren request-response-stream katmanı.
|
## Demo dosyaları
|
||||||
- **Sanal adresleme (IPPressure):** uzun socket hash'leri yerine sanal IP / numara / kısa kod (ör. `15.214.11.74`, `884`, `ZQT`) atayıp eşleri kolayca çağırma.
|
|
||||||
- **Oda sistemi:** `free` / `invite` / `password` / `lock` katılım türleri, per-connection okuma/yazma/bildirim ayarları.
|
|
||||||
|
|
||||||
## Yol haritası
|
Sunucu çalışırken `http://localhost:7707/demos/` altında:
|
||||||
|
|
||||||
| Sürüm | İçerik |
|
| Demo | Dosya | Açıklama |
|
||||||
|------|--------|
|
|---|---|---|
|
||||||
| **0.1.0** | Go engine çekirdeği + concurrency (goroutine/channel/mutex) + `go test -race` süreç testleri |
|
| Chat | `chat.html` | ~20 satır JS ile odalı gerçek zamanlı sohbet |
|
||||||
| **1.0.0** | Go + Frontend + WebRTC tam parite (oda, pairing, sanal adres, tünel, signaling, demolar) |
|
| Sesli görüşme | `audio.html` | P2P WebRTC mikrofon (çift yönlü) |
|
||||||
| **2.0.0** | WebRTC Studio: tam WebRTC API, çoklu track/kamera, canvas compositing, SRS yayını, sanal IP çakışma + alt-network |
|
| Video görüşme | `video.html` | P2P WebRTC kamera ızgara görünümü |
|
||||||
| **2.5.0** | JSON → binary protokol |
|
|
||||||
| **3.0.0** | Platform: Notify (offline/suit), aktif/pasif sync + datastore, 3. parti sunucu köprüsü |
|
|
||||||
|
|
||||||
Detaylı görevler **Issues** ve milestone'larda. Tasarım bağlamı: `todo.md`.
|
## API kontrolü (/api)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API anahtarı al
|
||||||
|
KEY=$(curl -s -X POST localhost:7707/api/auth/key \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"domain":"myapp"}' | jq -r .key)
|
||||||
|
|
||||||
|
# Tüm odaları listele
|
||||||
|
curl -s localhost:7707/api/rooms | jq .
|
||||||
|
|
||||||
|
# Belirli bir istemciye mesaj gönder
|
||||||
|
curl -s -X POST localhost:7707/api/client/<id>/send \
|
||||||
|
-H "x-api-key: $KEY" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"pack": {"hello": "world"}}'
|
||||||
|
|
||||||
|
# 3. parti köprü — inbox boşalt
|
||||||
|
curl -s -X POST localhost:7707/api/bridge/inbox \
|
||||||
|
-H "x-api-key: $KEY"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mimari
|
||||||
|
|
||||||
|
```
|
||||||
|
Tarayıcı (WebSocket)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ws.Hub — router + client registry
|
||||||
|
├─ services/yourid.go Bağlantı açılınca wsts/hello + id sinyalleri
|
||||||
|
├─ services/auth.go Pairing, erişilebilirlik
|
||||||
|
├─ services/room.go Oda oluşturma / yönetimi
|
||||||
|
├─ services/datatransfer.go pack/to, request/to, response/to tünelleri
|
||||||
|
├─ services/notify.go Store-and-forward bildirim + suit yanıtı
|
||||||
|
├─ services/datastore.go Aktif & pasif veri senkronizasyonu
|
||||||
|
├─ services/bridge.go 3. parti sunucu inbox
|
||||||
|
├─ services/ippressure.go Sanal IP / numara / kısa kod + alt ağ (/24)
|
||||||
|
└─ services/session.go Oturum bayrakları (readable/writable/pairinfo)
|
||||||
|
|
||||||
|
httpserver
|
||||||
|
├─ GET /sdk.js → /sdk/index.js (301 yönlendirme)
|
||||||
|
├─ GET /sdk/* ES modül SDK dosyaları
|
||||||
|
├─ GET /demos/* Demo HTML dosyaları
|
||||||
|
├─ GET|POST /api/* Kontrol düzlemi
|
||||||
|
└─ GET /* Statik public/ dosyaları
|
||||||
|
|
||||||
|
sdk/
|
||||||
|
├─ index.js MWSE ana sınıf (bağlantı + sinyal yönlendirme)
|
||||||
|
├─ webrtc/ WebRTC kütüphanesi
|
||||||
|
│ ├─ index.js RTCEngine (PeerConnection yönetimi, ICE restart)
|
||||||
|
│ ├─ PeerConnection.js RTCPeerConnection wrapper + full event izleme
|
||||||
|
│ ├─ Negotiator.js Perfect negotiation (RFC 8829)
|
||||||
|
│ ├─ StreamManager.js addStream/replaceTrack/setEncodings
|
||||||
|
│ ├─ DataChannel.js Birincil veri kanalı + oto-yeniden bağlanma
|
||||||
|
│ ├─ MediaSources.js getUserMedia/getDisplayMedia/AudioContext fabrikaları
|
||||||
|
│ ├─ FileSender.js Paralel DataChannel dosya transferi
|
||||||
|
│ └─ CanvasCompositor.js Çoklu video track birleştirme (grid/pip/focus)
|
||||||
|
└─ studio/
|
||||||
|
├─ index.js Studio UI giriş noktası
|
||||||
|
├─ ColumnView.js Miller kolon yöneticisi
|
||||||
|
├─ Column.js Tek kolon (başlık + arama + liste)
|
||||||
|
└─ style.css Koyu masaüstü teması
|
||||||
|
```
|
||||||
|
|
||||||
## Güvenlik
|
## Güvenlik
|
||||||
|
|
||||||
MWSE mesajları doğru hedefe ve bozulmadan iletmekten sorumludur. Ancak istemcilerin gönderdiği mesajlar **sokete iletilmeden önce** kullanıcı tarafında manipüle/taklit edilebilir; MWSE bunu **doğrulamaz**. Hassas verileri uygulama katmanında doğrula/şifrele.
|
- İstemci mesajları **uygulama katmanında doğrulanmaz**. Hassas veriler için
|
||||||
|
imzalama/şifreleme ekleyin.
|
||||||
|
- 3. parti köprü (`BRIDGE_APPROVE_URL`) kullanılıyorsa bağlantı onayı uygulama
|
||||||
|
sunucusuna delege edilir (fail-closed).
|
||||||
|
- `.gitea-auth.json` dosyası commit'e asla girmez (`.gitignore`'da).
|
||||||
|
|
||||||
## Lisans
|
## Geliştirici dökümanı
|
||||||
|
|
||||||
Bkz. `LICENSE`.
|
Wiki: <https://git.saqut.com/saqut/MWSE/wiki>
|
||||||
|
|
|
||||||
|
|
@ -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) — ÇÖZÜLDÜ (#33)
|
||||||
|
|
||||||
|
**Önceki sorun:** SDK request'i action 'R' ile gönderdiğinden sunucu anında `[null, id, 'E']` yanıtlıyordu; eşin asıl cevabı `response/to` ile sonra geliyordu ama o id SDK tarafında zaten silinmiş oluyordu (cevap kayboluyordu / `mwse.request` patlıyordu).
|
||||||
|
|
||||||
|
**Çözüm (#33):** Dispatcher artık handler `nil` döndürdüğünde **yanıt göndermiyor** (`nil` = "ben yanıt vermeyeceğim / cevap out-of-band gelecek"). `request/to` nil döndürür → erken `[null,id,'E']` yok → asıl cevap `response/to` ile aynı id üzerinden gelir. SDK tarafında WOM yolu `EventPool.only()` ile `request()`'ten ayrıldı. Bu, donmuş sözleşmenin **şekillerini** değiştirmez; yalnızca SDK'nın kullanamadığı sahte bir `null`-`E` frame'ini kaldırır. Regression: `TestServerNoReplyOnNilResult`, `TestRequestResponseRoundTrip`.
|
||||||
|
|
||||||
|
## 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`).
|
||||||
213
Source/Client.js
213
Source/Client.js
|
|
@ -1,213 +0,0 @@
|
||||||
function Client()
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @type {string}
|
|
||||||
*/
|
|
||||||
this.id = null;
|
|
||||||
/**
|
|
||||||
* @type {import("websocket").connection}
|
|
||||||
*/
|
|
||||||
this.socket = null;
|
|
||||||
/**
|
|
||||||
* @type {Date}
|
|
||||||
*/
|
|
||||||
this.created_at = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {Map<string,any>}
|
|
||||||
*/
|
|
||||||
this.info = new Map();
|
|
||||||
/**
|
|
||||||
* @type {Map<string,any>}
|
|
||||||
*/
|
|
||||||
this.store = new Map();
|
|
||||||
/**
|
|
||||||
* @type {Set<string>}
|
|
||||||
*/
|
|
||||||
this.rooms = new Set();
|
|
||||||
/**
|
|
||||||
* @type {Set<string>}
|
|
||||||
*/
|
|
||||||
this.pairs = new Set();
|
|
||||||
this.requiredPair = false;
|
|
||||||
|
|
||||||
this.APNumber = 0;
|
|
||||||
this.APShortCode = 0;
|
|
||||||
this.APIPAddress = 0;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* @type {Map<string, Client>}
|
|
||||||
*/
|
|
||||||
Client.clients = new Map();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Client} client
|
|
||||||
*/
|
|
||||||
Client.prototype.peerRequest = function(client){
|
|
||||||
let info = {};
|
|
||||||
this.info.forEach((value, name) => info[name] = value);
|
|
||||||
this.pairs.add(client.id);
|
|
||||||
client.send([
|
|
||||||
{ from: this.id },
|
|
||||||
'request/pair'
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
Client.prototype.match = function(filterObject){
|
|
||||||
let keys = Object.keys(filterObject);
|
|
||||||
let size = keys.length;
|
|
||||||
if(size > this.info.size)
|
|
||||||
{
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for (const key of keys)
|
|
||||||
{
|
|
||||||
if(this.info.has(key))
|
|
||||||
{
|
|
||||||
if(this.info.get(key) != filterObject[key])
|
|
||||||
{
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}else{
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return true
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Client|string} client
|
|
||||||
*/
|
|
||||||
Client.prototype.isSecure = function(client)
|
|
||||||
{
|
|
||||||
const { Room } = require("./Services/Room");
|
|
||||||
if(typeof client == "string")
|
|
||||||
{
|
|
||||||
if(Client.clients.has(client))
|
|
||||||
{
|
|
||||||
client = Client.clients.get(client);
|
|
||||||
}else return false;
|
|
||||||
}else if(!(client instanceof Client)){
|
|
||||||
console.error("isSecure Client bir client veri tipinde değil")
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Eşleştirilmiş kullanıcı
|
|
||||||
if(this.isPaired(client))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aynı odada bulunan kullanıcı
|
|
||||||
for (const id of this.rooms) {
|
|
||||||
let room = Room.rooms.get(id);
|
|
||||||
if(room)
|
|
||||||
{
|
|
||||||
if(room.clients.has(id))
|
|
||||||
{
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* @returns {{pairs:Map<string, Client>,roompairs:Map<string, Client>,intersection:Map<string, Client>}}
|
|
||||||
*/
|
|
||||||
Client.prototype.getSucureClients = function()
|
|
||||||
{
|
|
||||||
const { Room } = require("./Services/Room");
|
|
||||||
let pairs = new Map();
|
|
||||||
let roompairs = new Map();
|
|
||||||
|
|
||||||
for (const id of this.pairs)
|
|
||||||
{
|
|
||||||
pairs.set(id, Client.clients.get(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aynı odada bulunan kullanıcı
|
|
||||||
for (const id of this.rooms) {
|
|
||||||
let room = Room.rooms.get(id);
|
|
||||||
if(room)
|
|
||||||
{
|
|
||||||
for (const [id, client] of room.clients)
|
|
||||||
{
|
|
||||||
if(id == this.id) continue;
|
|
||||||
roompairs.set(id, client)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
pairs,
|
|
||||||
roompairs,
|
|
||||||
intersection : new Map([
|
|
||||||
...pairs,
|
|
||||||
...roompairs
|
|
||||||
])
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Client} client
|
|
||||||
*/
|
|
||||||
Client.prototype.acceptPeerRequest = function(client){
|
|
||||||
this.pairs.add(client.id);
|
|
||||||
client.send([{
|
|
||||||
from: this.id
|
|
||||||
},'accepted/pair']);
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* @param {Client} client
|
|
||||||
*/
|
|
||||||
Client.prototype.rejectPeerRequest = function(client){
|
|
||||||
this.pairs.delete(client.id);
|
|
||||||
client.pairs.delete(this.id);
|
|
||||||
client.send([{
|
|
||||||
from: this.id
|
|
||||||
},'end/pair']);
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* @param {Client|string} client
|
|
||||||
* @returns {Boolean}
|
|
||||||
*/
|
|
||||||
Client.prototype.isPaired = function(client){
|
|
||||||
if(typeof client == "string")
|
|
||||||
{
|
|
||||||
return Client.clients.get(client)?.pairs.has(this.id) && this.pairs.has(client)
|
|
||||||
}
|
|
||||||
return client.pairs.has(this.id) && this.pairs.has(client.id);
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* @returns {string[]}
|
|
||||||
*/
|
|
||||||
Client.prototype.pairList = function(){
|
|
||||||
return [...this.pairs.values()].filter(e => this.isPaired(e));
|
|
||||||
};
|
|
||||||
|
|
||||||
Client.prototype.send = function(obj){
|
|
||||||
if(this.socket.connected){
|
|
||||||
this.socket.sendUTF(JSON.stringify(obj),err => {
|
|
||||||
if(err && this.socket)
|
|
||||||
{
|
|
||||||
console.error("I/O: Hatalı yazma işlemi yapıldı",err.message)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}else{
|
|
||||||
console.error("Bağlantısı kopmuş yazma işlemi")
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Client.prototype.packWriteable = function(){
|
|
||||||
return !!this.store.get("packrecaive")
|
|
||||||
}
|
|
||||||
Client.prototype.packReadable = function(){
|
|
||||||
return !!this.store.get("packsending")
|
|
||||||
}
|
|
||||||
Client.prototype.peerInfoNotifiable = function(){
|
|
||||||
return !!this.store.get("notifyPairInfo")
|
|
||||||
}
|
|
||||||
Client.prototype.roomInfoNotifiable = function(){
|
|
||||||
return !!this.store.get("notifyRoomInfo")
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.Client = Client;
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const events = new Map();
|
|
||||||
|
|
||||||
function on(event, callback) {
|
|
||||||
if (!events.has(event)) {
|
|
||||||
events.set(event, []);
|
|
||||||
}
|
|
||||||
events.get(event).push(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
function emit(event, ...args) {
|
|
||||||
if (events.has(event)) {
|
|
||||||
for (const callback of events.get(event)) {
|
|
||||||
try {
|
|
||||||
callback(...args);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Event error [${event}]:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function once(event, callback) {
|
|
||||||
const wrapper = (...args) => {
|
|
||||||
off(event, wrapper);
|
|
||||||
callback(...args);
|
|
||||||
};
|
|
||||||
on(event, wrapper);
|
|
||||||
}
|
|
||||||
|
|
||||||
function off(event, callback) {
|
|
||||||
if (events.has(event)) {
|
|
||||||
const callbacks = events.get(event);
|
|
||||||
const index = callbacks.indexOf(callback);
|
|
||||||
if (index > -1) {
|
|
||||||
callbacks.splice(index, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeAllListeners(event) {
|
|
||||||
if (event) {
|
|
||||||
events.delete(event);
|
|
||||||
} else {
|
|
||||||
events.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function listenerCount(event) {
|
|
||||||
return events.has(event) ? events.get(event).length : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
on,
|
|
||||||
emit,
|
|
||||||
once,
|
|
||||||
off,
|
|
||||||
removeAllListeners,
|
|
||||||
listenerCount,
|
|
||||||
events
|
|
||||||
};
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
"use strict";
|
|
||||||
|
|
||||||
let http = require("http");
|
|
||||||
let express = require("express");
|
|
||||||
let compression = require("compression");
|
|
||||||
let {resolve} = require("path");
|
|
||||||
|
|
||||||
const { termoutput } = require("./config");
|
|
||||||
|
|
||||||
let app = express();
|
|
||||||
app.use(compression({ level: 9 }));
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(express.urlencoded({ extended: true }));
|
|
||||||
|
|
||||||
let server = http.createServer(app);
|
|
||||||
|
|
||||||
server.listen(7707, '0.0.0.0', () => {
|
|
||||||
termoutput && console.log("HTTP Service Running...");
|
|
||||||
});
|
|
||||||
|
|
||||||
server.on("error", (err) => {
|
|
||||||
console.error(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
exports.http = server;
|
|
||||||
|
|
||||||
const apiRouter = require("./api");
|
|
||||||
app.use("/api", apiRouter);
|
|
||||||
|
|
||||||
app.get("/script", (req, res) => {
|
|
||||||
res.sendFile(resolve("./script/index.js"));
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use(express.static(resolve("./public")));
|
|
||||||
app.use("/script", express.static(resolve("./script")));
|
|
||||||
|
|
||||||
app.get("/", (req, res) => {
|
|
||||||
res.sendFile(resolve("./script/index.js"));
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("*", (req, res) => {
|
|
||||||
res.sendFile(resolve("./script/status.xml"));
|
|
||||||
});
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const handlers = new Map();
|
|
||||||
|
|
||||||
function register(type, handler) {
|
|
||||||
handlers.set(type, handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handle(client, message) {
|
|
||||||
const { type } = message;
|
|
||||||
|
|
||||||
if (!type) {
|
|
||||||
return { status: 'fail', message: 'MISSING_TYPE' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const handler = handlers.get(type);
|
|
||||||
|
|
||||||
if (!handler) {
|
|
||||||
return { status: 'fail', message: 'UNKNOWN_TYPE' };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = handler(client, message);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Handler error [${type}]:`, error);
|
|
||||||
return { status: 'fail', message: 'HANDLER_ERROR', error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function unregister(type) {
|
|
||||||
handlers.delete(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clear() {
|
|
||||||
handlers.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasHandler(type) {
|
|
||||||
return handlers.has(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
function listHandlers() {
|
|
||||||
return [...handlers.keys()];
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
register,
|
|
||||||
handle,
|
|
||||||
unregister,
|
|
||||||
clear,
|
|
||||||
hasHandler,
|
|
||||||
listHandlers
|
|
||||||
};
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const { Client } = require("../Client.js");
|
|
||||||
const { on, emit, register } = require("../WebSocket");
|
|
||||||
|
|
||||||
on('disconnect', (xclient) => {
|
|
||||||
const { intersection, pairs } = xclient.getSucureClients();
|
|
||||||
|
|
||||||
for (const [clientid, client] of intersection) {
|
|
||||||
client?.send([{ id: xclient.id }, "peer/disconnect"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, peer] of pairs) {
|
|
||||||
peer?.pairs.delete(xclient.id);
|
|
||||||
xclient.pairs.delete(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
register('auth/pair-system', (client, msg) => {
|
|
||||||
if (msg.value == 'everybody') {
|
|
||||||
client.requiredPair = true;
|
|
||||||
return { status: 'success' };
|
|
||||||
}
|
|
||||||
if (msg.value == 'disable') {
|
|
||||||
client.requiredPair = false;
|
|
||||||
return { status: 'success' };
|
|
||||||
}
|
|
||||||
return { status: 'fail', message: 'INVALID_VALUE' };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('my/socketid', (client, msg) => {
|
|
||||||
return client.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
register('auth/public', (client, msg) => {
|
|
||||||
client.requiredPair = false;
|
|
||||||
return { value: 'success', mode: 'public' };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('auth/private', (client, msg) => {
|
|
||||||
client.requiredPair = true;
|
|
||||||
return { value: 'success', mode: 'private' };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('request/pair', (client, msg) => {
|
|
||||||
const { to } = msg;
|
|
||||||
|
|
||||||
if (!Client.clients.has(to)) {
|
|
||||||
return { status: 'fail', message: 'CLIENT_NOT_FOUND' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const pairclient = Client.clients.get(to);
|
|
||||||
|
|
||||||
if (pairclient.pairs.has(client.id)) {
|
|
||||||
return { status: 'success', message: 'ALREADY-PAIRED' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (client.pairs.has(to)) {
|
|
||||||
return { status: 'fail', message: 'ALREADY-REQUESTED' };
|
|
||||||
}
|
|
||||||
|
|
||||||
client.peerRequest(pairclient);
|
|
||||||
return { status: 'success', message: 'REQUESTED' };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('accept/pair', (client, msg) => {
|
|
||||||
const { to } = msg;
|
|
||||||
|
|
||||||
if (!Client.clients.has(to)) {
|
|
||||||
return { status: 'fail', message: 'CLIENT_NOT_FOUND' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const pairclient = Client.clients.get(to);
|
|
||||||
|
|
||||||
if (pairclient.pairs.has(client.id)) {
|
|
||||||
return { status: 'success', message: 'ALREADY-PAIRED' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!client.pairs.has(to)) {
|
|
||||||
return { status: 'fail', message: 'NOT_REQUESTED_PAIR' };
|
|
||||||
}
|
|
||||||
|
|
||||||
client.acceptPeerRequest(pairclient);
|
|
||||||
return { status: 'success' };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('reject/pair', (client, msg) => {
|
|
||||||
const { to } = msg;
|
|
||||||
|
|
||||||
if (!Client.clients.has(to)) {
|
|
||||||
return { status: 'fail', message: 'CLIENT_NOT_FOUND' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const pairclient = Client.clients.get(to);
|
|
||||||
|
|
||||||
if (pairclient.pairs.has(client.id)) {
|
|
||||||
return { status: 'success', message: 'ALREADY-PAIRED' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!client.pairs.has(to)) {
|
|
||||||
return { status: 'fail', message: 'NOT_REQUESTED_PAIR' };
|
|
||||||
}
|
|
||||||
|
|
||||||
client.rejectPeerRequest(pairclient);
|
|
||||||
return { status: 'success' };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('end/pair', (client, msg) => {
|
|
||||||
const { to } = msg;
|
|
||||||
|
|
||||||
if (!Client.clients.has(to)) {
|
|
||||||
return { status: 'fail', message: 'CLIENT_NOT_FOUND' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const pairclient = Client.clients.get(to);
|
|
||||||
|
|
||||||
if (!pairclient.pairs.has(client.id)) {
|
|
||||||
return { status: 'success', message: 'NOT_PAIRED' };
|
|
||||||
}
|
|
||||||
|
|
||||||
client.rejectPeerRequest(pairclient);
|
|
||||||
return { status: 'success' };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('pair/list', (client, msg) => {
|
|
||||||
return { type: 'pair/list', value: client.pairList() };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('is/reachable', (client, msg) => {
|
|
||||||
const { to } = msg;
|
|
||||||
|
|
||||||
if (!Client.clients.has(to)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherPeer = Client.clients.get(to);
|
|
||||||
|
|
||||||
if (otherPeer.requiredPair && !otherPeer.pairs.has(to)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
register('auth/info', (client, msg) => {
|
|
||||||
const { name, value } = msg;
|
|
||||||
|
|
||||||
client.info.set(name, value);
|
|
||||||
|
|
||||||
const clients = client.getSucureClients();
|
|
||||||
|
|
||||||
for (const [, spair] of clients.pairs) {
|
|
||||||
spair.send([{ from: client.id, name, value }, "pair/info"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [, spair] of clients.roompairs) {
|
|
||||||
spair.send([{ from: client.id, name, value }, "pair/info"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { status: 'success' };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('peer/info', (client, msg) => {
|
|
||||||
const { peer } = msg;
|
|
||||||
|
|
||||||
if (!client.isSecure(peer)) {
|
|
||||||
return { status: "fail", message: "unaccessible user" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const peerClient = Client.clients.get(peer);
|
|
||||||
const info = {};
|
|
||||||
peerClient.info.forEach((value, name) => { info[name] = value; });
|
|
||||||
|
|
||||||
return { status: "success", info };
|
|
||||||
});
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const { Client } = require("../Client.js");
|
|
||||||
const { register } = require("../WebSocket");
|
|
||||||
const { Room } = require("./Room");
|
|
||||||
|
|
||||||
register('pack/to', (client, msg) => {
|
|
||||||
const { to, pack, handshake } = msg;
|
|
||||||
|
|
||||||
if (!client.packReadable()) {
|
|
||||||
return handshake ? { type: 'fail' } : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Client.clients.has(to)) {
|
|
||||||
return handshake ? { type: 'fail' } : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherPeer = Client.clients.get(to);
|
|
||||||
|
|
||||||
if (otherPeer.requiredPair) {
|
|
||||||
if (!otherPeer.pairs.has(to)) {
|
|
||||||
return handshake ? { type: 'fail' } : undefined;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!otherPeer.pairs.has(to)) {
|
|
||||||
otherPeer.pairs.add(client.id);
|
|
||||||
client.pairs.add(otherPeer.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!otherPeer.packWriteable()) {
|
|
||||||
return handshake ? { type: 'fail' } : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
otherPeer.send([{ from: client.id, pack }, 'pack']);
|
|
||||||
|
|
||||||
return handshake ? { type: 'success' } : undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
register('request/to', (client, msg) => {
|
|
||||||
const { to, pack } = msg;
|
|
||||||
|
|
||||||
if (!Client.clients.has(to)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherPeer = Client.clients.get(to);
|
|
||||||
|
|
||||||
if (otherPeer.requiredPair) {
|
|
||||||
if (!otherPeer.pairs.has(to)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
otherPeer.pairs.add(client.id);
|
|
||||||
client.pairs.add(otherPeer.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
otherPeer.send([{ from: client.id, pack }, 'request']);
|
|
||||||
});
|
|
||||||
|
|
||||||
register('response/to', (client, msg) => {
|
|
||||||
const { to, pack, id } = msg;
|
|
||||||
|
|
||||||
if (!Client.clients.has(to)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherPeer = Client.clients.get(to);
|
|
||||||
|
|
||||||
if (otherPeer.requiredPair && !otherPeer.pairs.has(to)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
otherPeer.send([{ from: client.id, pack }, id]);
|
|
||||||
});
|
|
||||||
|
|
||||||
register('pack/room', (client, msg) => {
|
|
||||||
const { to, pack, handshake, wom } = msg;
|
|
||||||
|
|
||||||
if (!client.packReadable()) {
|
|
||||||
return handshake ? { type: 'fail' } : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Room.rooms.has(to)) {
|
|
||||||
return handshake ? { type: 'fail' } : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!client.rooms.has(to)) {
|
|
||||||
return handshake ? { type: 'fail' } : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const room = Room.rooms.get(to);
|
|
||||||
|
|
||||||
room.send(
|
|
||||||
[{ from: to, pack, sender: client.id }, 'pack/room'],
|
|
||||||
wom ? client.id : undefined,
|
|
||||||
c => c.packWriteable()
|
|
||||||
);
|
|
||||||
|
|
||||||
return handshake ? { type: 'success' } : undefined;
|
|
||||||
});
|
|
||||||
|
|
@ -1,269 +0,0 @@
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const { Client } = require("../Client");
|
|
||||||
const { on, register } = require("../WebSocket");
|
|
||||||
|
|
||||||
class APNumber {
|
|
||||||
static busyNumbers = new Map();
|
|
||||||
|
|
||||||
static lock(client) {
|
|
||||||
let c = 24;
|
|
||||||
while (true) {
|
|
||||||
if (!APNumber.busyNumbers.has(c)) {
|
|
||||||
APNumber.busyNumbers.set(c, client);
|
|
||||||
process.send({
|
|
||||||
type: 'AP_NUMBER/LOCK',
|
|
||||||
uuid: client.id,
|
|
||||||
value: c
|
|
||||||
});
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
c++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static release(num) {
|
|
||||||
process.send({
|
|
||||||
type: 'AP_NUMBER/RELEASE',
|
|
||||||
uuid: APNumber.busyNumbers.get(num).id,
|
|
||||||
value: num
|
|
||||||
});
|
|
||||||
APNumber.busyNumbers.delete(num);
|
|
||||||
}
|
|
||||||
|
|
||||||
static whois(num) {
|
|
||||||
return APNumber.busyNumbers.get(num)?.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class APShortCode {
|
|
||||||
static busyCodes = new Map();
|
|
||||||
|
|
||||||
static lock(client) {
|
|
||||||
let firstLetter = new ShortCodeLetter();
|
|
||||||
let secondLetter = new ShortCodeLetter();
|
|
||||||
let thirdLetter = new ShortCodeLetter();
|
|
||||||
|
|
||||||
while (1) {
|
|
||||||
let code = [firstLetter.code, secondLetter.code, thirdLetter.code].join('');
|
|
||||||
if (!APShortCode.busyCodes.has(code)) {
|
|
||||||
APShortCode.busyCodes.set(code, client);
|
|
||||||
process.send({
|
|
||||||
type: 'AP_SHORTCODE/LOCK',
|
|
||||||
uuid: APShortCode.busyCodes.get(code).id,
|
|
||||||
value: code
|
|
||||||
});
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!thirdLetter.end()) {
|
|
||||||
thirdLetter.next();
|
|
||||||
} else {
|
|
||||||
thirdLetter.reset();
|
|
||||||
if (!secondLetter.end()) {
|
|
||||||
secondLetter.next();
|
|
||||||
} else {
|
|
||||||
secondLetter.reset();
|
|
||||||
if (!firstLetter.end()) {
|
|
||||||
firstLetter.next();
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static release(code) {
|
|
||||||
if (APShortCode.busyCodes.has(code)) {
|
|
||||||
process.send({
|
|
||||||
type: 'AP_SHORTCODE/RELEASE',
|
|
||||||
uuid: APShortCode.busyCodes.get(code).id,
|
|
||||||
value: code
|
|
||||||
});
|
|
||||||
APShortCode.busyCodes.delete(code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static whois(num) {
|
|
||||||
return APShortCode.busyCodes.get(num)?.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ShortCodeLetter {
|
|
||||||
chars = 'ABCDEFGHIKLMNOPRSTVXYZ'.split('');
|
|
||||||
now = 0;
|
|
||||||
code = 'A';
|
|
||||||
|
|
||||||
next() {
|
|
||||||
this.now++;
|
|
||||||
this.code = this.chars.at(this.now);
|
|
||||||
return this.code;
|
|
||||||
}
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.now = 0;
|
|
||||||
this.code = 'A';
|
|
||||||
}
|
|
||||||
|
|
||||||
end() {
|
|
||||||
return !this.chars.at(this.now + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class APIPAddress {
|
|
||||||
static busyIP = new Map();
|
|
||||||
|
|
||||||
static lock(client) {
|
|
||||||
let A = 10, B = 0, C = 0, D = 1;
|
|
||||||
|
|
||||||
while (1) {
|
|
||||||
let code = [A, B, C, D].join('.');
|
|
||||||
if (!APIPAddress.busyIP.has(code)) {
|
|
||||||
APIPAddress.busyIP.set(code, client);
|
|
||||||
process.send({
|
|
||||||
type: 'AP_IPADDRESS/LOCK',
|
|
||||||
uuid: APIPAddress.busyIP.get(code).id,
|
|
||||||
value: code
|
|
||||||
});
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (D != 255) { D++; continue; }
|
|
||||||
D = 0;
|
|
||||||
if (C != 255) { C++; continue; }
|
|
||||||
C = 0;
|
|
||||||
if (B != 255) { B++; continue; }
|
|
||||||
B = 0;
|
|
||||||
if (A != 255) { A++; continue; }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static release(code) {
|
|
||||||
if (APIPAddress.busyIP.has(code)) {
|
|
||||||
process.send({
|
|
||||||
type: 'AP_IPADDRESS/RELEASE',
|
|
||||||
uuid: APIPAddress.busyIP.get(code).id,
|
|
||||||
value: code
|
|
||||||
});
|
|
||||||
APIPAddress.busyIP.delete(code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static whois(num) {
|
|
||||||
return APIPAddress.busyIP.get(num)?.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.APNumber = APNumber;
|
|
||||||
exports.APShortCode = APShortCode;
|
|
||||||
exports.APIPAddress = APIPAddress;
|
|
||||||
|
|
||||||
register('alloc/APIPAddress', (client, msg) => {
|
|
||||||
if (client.APIPAddress) {
|
|
||||||
return { status: 'success', ip: client.APIPAddress };
|
|
||||||
}
|
|
||||||
let value = APIPAddress.lock(client);
|
|
||||||
client.APIPAddress = value;
|
|
||||||
return { status: 'success', ip: value };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('alloc/APNumber', (client, msg) => {
|
|
||||||
if (client.APNumber) {
|
|
||||||
return { status: 'success', number: client.APNumber };
|
|
||||||
}
|
|
||||||
let value = APNumber.lock(client);
|
|
||||||
client.APNumber = value;
|
|
||||||
return { status: 'success', number: value };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('alloc/APShortCode', (client, msg) => {
|
|
||||||
if (client.APShortCode) {
|
|
||||||
return { status: 'success', code: client.APShortCode };
|
|
||||||
}
|
|
||||||
let value = APShortCode.lock(client);
|
|
||||||
client.APShortCode = value;
|
|
||||||
return { status: 'success', code: value };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('realloc/APIPAddress', (client, msg) => {
|
|
||||||
if (client.APIPAddress == 0) {
|
|
||||||
return { status: 'fail' };
|
|
||||||
}
|
|
||||||
APIPAddress.release(client.APIPAddress);
|
|
||||||
let value = APIPAddress.lock(client);
|
|
||||||
return { status: 'success', ip: value };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('realloc/APNumber', (client, msg) => {
|
|
||||||
if (client.APNumber == 0) {
|
|
||||||
return { status: 'fail' };
|
|
||||||
}
|
|
||||||
APNumber.release(client.APNumber);
|
|
||||||
let value = APNumber.lock(client);
|
|
||||||
return { status: 'success', number: value };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('realloc/APShortCode', (client, msg) => {
|
|
||||||
if (client.APShortCode == 0) {
|
|
||||||
return { status: 'fail' };
|
|
||||||
}
|
|
||||||
APShortCode.release(client.APShortCode);
|
|
||||||
let value = APShortCode.lock(client);
|
|
||||||
return { status: 'success', code: value };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('release/APIPAddress', (client, msg) => {
|
|
||||||
APIPAddress.release(client.APIPAddress);
|
|
||||||
client.APIPAddress = undefined;
|
|
||||||
return { status: 'success' };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('release/APNumber', (client, msg) => {
|
|
||||||
APNumber.release(client.APNumber);
|
|
||||||
client.APNumber = undefined;
|
|
||||||
return { status: 'success' };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('release/APShortCode', (client, msg) => {
|
|
||||||
APShortCode.release(client.APShortCode);
|
|
||||||
client.APShortCode = undefined;
|
|
||||||
return { status: 'success' };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('whois/APIPAddress', (client, msg) => {
|
|
||||||
let socketId = APIPAddress.whois(msg.whois);
|
|
||||||
if (socketId) {
|
|
||||||
return { status: 'success', socket: socketId };
|
|
||||||
}
|
|
||||||
return { status: 'fail' };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('whois/APNumber', (client, msg) => {
|
|
||||||
let socketId = APNumber.whois(msg.whois);
|
|
||||||
if (socketId) {
|
|
||||||
return { status: 'success', socket: socketId };
|
|
||||||
}
|
|
||||||
return { status: 'fail' };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('whois/APShortCode', (client, msg) => {
|
|
||||||
let socketId = APShortCode.whois(msg.whois);
|
|
||||||
if (socketId) {
|
|
||||||
return { status: 'success', socket: socketId };
|
|
||||||
}
|
|
||||||
return { status: 'fail' };
|
|
||||||
});
|
|
||||||
|
|
||||||
on('disconnect', (client) => {
|
|
||||||
if (client.APIPAddress != 0) {
|
|
||||||
APIPAddress.release(client.APIPAddress);
|
|
||||||
}
|
|
||||||
if (client.APNumber != 0) {
|
|
||||||
APNumber.release(client.APNumber);
|
|
||||||
}
|
|
||||||
if (client.APShortCode != 0) {
|
|
||||||
APShortCode.release(client.APShortCode);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,549 +0,0 @@
|
||||||
const { Client } = require("../Client.js");
|
|
||||||
let { randomUUID, createHash } = require("crypto");
|
|
||||||
const joi = require("joi");
|
|
||||||
const { on, register } = require("../WebSocket");
|
|
||||||
const { termoutput } = require("../config.js");
|
|
||||||
let term = require("terminal-kit").terminal;
|
|
||||||
|
|
||||||
function Sha256(update) {
|
|
||||||
return createHash("sha256").update(update).digest("hex");
|
|
||||||
};
|
|
||||||
|
|
||||||
function Room()
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @type {string}
|
|
||||||
*/
|
|
||||||
this.id = randomUUID();
|
|
||||||
/**
|
|
||||||
* @type {string}
|
|
||||||
*/
|
|
||||||
this.name = "";
|
|
||||||
/**
|
|
||||||
* @type {string}
|
|
||||||
*/
|
|
||||||
this.description = "";
|
|
||||||
/**
|
|
||||||
* @type {Client}
|
|
||||||
*/
|
|
||||||
this.owner = null;
|
|
||||||
/**
|
|
||||||
* @type {Date}
|
|
||||||
*/
|
|
||||||
this.createdAt = new Date();
|
|
||||||
/**
|
|
||||||
* @type {Map<string, Client>}
|
|
||||||
*/
|
|
||||||
this.clients = new Map();
|
|
||||||
/**
|
|
||||||
* @type {"public"|"private"}
|
|
||||||
*/
|
|
||||||
this.accessType = "";
|
|
||||||
/**
|
|
||||||
* @type {"free"|"invite"|"password"|"lock"}
|
|
||||||
*/
|
|
||||||
this.joinType = "invite";
|
|
||||||
/**
|
|
||||||
* @type {boolean}
|
|
||||||
*/
|
|
||||||
this.notifyActionInvite = false;
|
|
||||||
/**
|
|
||||||
* @type {boolean}
|
|
||||||
*/
|
|
||||||
this.notifyActionJoined = true;
|
|
||||||
/**
|
|
||||||
* @type {boolean}
|
|
||||||
*/
|
|
||||||
this.notifyActionEjected = true;
|
|
||||||
/**
|
|
||||||
* @type {string}
|
|
||||||
*/
|
|
||||||
this.credential = null;
|
|
||||||
/**
|
|
||||||
* @type {string[]}
|
|
||||||
*/
|
|
||||||
this.waitingInvited = new Set();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {Map<string,any>}
|
|
||||||
*/
|
|
||||||
this.info = new Map();
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* @param {Room} room
|
|
||||||
*/
|
|
||||||
Room.prototype.publish = function(){
|
|
||||||
Room.rooms.set(this.id, this);
|
|
||||||
termoutput && term.green("Room Published ").white(this.name," in ").yellow(this.clients.size).white(" clients")('\n');
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* @return {Client[]}
|
|
||||||
*/
|
|
||||||
Room.prototype.filterPeers = function(optiJson){
|
|
||||||
let peers = [];
|
|
||||||
this.clients.forEach(client => {
|
|
||||||
if(client.match(optiJson))
|
|
||||||
{
|
|
||||||
peers.push(client);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return peers;
|
|
||||||
};
|
|
||||||
Room.prototype.toJSON = function(detailed){
|
|
||||||
let obj = {};
|
|
||||||
obj.id = this.id;
|
|
||||||
obj.accessType = this.accessType;
|
|
||||||
obj.createdAt = this.createdAt;
|
|
||||||
obj.description = this.description;
|
|
||||||
obj.joinType = this.joinType;
|
|
||||||
obj.name = this.name;
|
|
||||||
obj.owner = this.owner.id;
|
|
||||||
obj.waitingInvited = [...this.waitingInvited];
|
|
||||||
if(detailed)
|
|
||||||
{
|
|
||||||
obj.credential = this.credential;
|
|
||||||
obj.notifyActionInvite = this.notifyActionInvite;
|
|
||||||
obj.notifyActionJoined = this.notifyActionJoined;
|
|
||||||
obj.notifyActionEjected = this.notifyActionEjected;
|
|
||||||
obj.clients = [...this.clients.keys()];
|
|
||||||
}
|
|
||||||
return obj;
|
|
||||||
};
|
|
||||||
Room.prototype.getInfo = function(){
|
|
||||||
let obj = {};
|
|
||||||
for (const [name, value] of this.info)
|
|
||||||
{
|
|
||||||
obj[name] = value;
|
|
||||||
}
|
|
||||||
return obj;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* @param {Object} data
|
|
||||||
* @param {Room} room
|
|
||||||
*/
|
|
||||||
Room.fromJSON = function(data, room){
|
|
||||||
room = room || new Room();
|
|
||||||
let obj = {};
|
|
||||||
room.id = data.id;
|
|
||||||
room.accessType = data.accessType;
|
|
||||||
room.createdAt = data.createdAt;
|
|
||||||
room.description = data.description;
|
|
||||||
room.joinType = data.joinType;
|
|
||||||
room.name = data.name;
|
|
||||||
if(data.owner && Client.clients.has(data.owner))
|
|
||||||
{
|
|
||||||
room.owner = Client.clients.get(data.owner);
|
|
||||||
}
|
|
||||||
room.waitingInvited = new Set(data.waitingInvited);
|
|
||||||
obj.credential = data.credential;
|
|
||||||
obj.notifyActionInvite = data.notifyActionInvite;
|
|
||||||
obj.notifyActionJoined = data.notifyActionJoined;
|
|
||||||
obj.notifyActionEjected = data.notifyActionEjected;
|
|
||||||
obj.clients = new Map(
|
|
||||||
data.clients.map(e => ([
|
|
||||||
e, // map key
|
|
||||||
Client.clients.get(e) // map value
|
|
||||||
])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return room;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param {any} obj
|
|
||||||
* @param {string} withOut
|
|
||||||
* @param {(client:Client) => boolean} map
|
|
||||||
*/
|
|
||||||
Room.prototype.send = function(obj, withOut, map){
|
|
||||||
for (const client of this.clients.values()) {
|
|
||||||
if(client.id != withOut)
|
|
||||||
{
|
|
||||||
(
|
|
||||||
map ? map(client) : 1
|
|
||||||
) && client.send(obj);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
termoutput && term.green("Room bulk message ").white(this.name," in ").yellow(this.clients.size + "").white(" clients")('\n');
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* @param {Client} client
|
|
||||||
*/
|
|
||||||
Room.prototype.join = function(client){
|
|
||||||
if(this.notifyActionJoined)
|
|
||||||
{
|
|
||||||
this.send(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
id: client.id,
|
|
||||||
roomid: this.id,
|
|
||||||
ownerid: this.owner.id
|
|
||||||
},
|
|
||||||
'room/joined'
|
|
||||||
],
|
|
||||||
void 0,
|
|
||||||
client => client.peerInfoNotifiable()
|
|
||||||
);
|
|
||||||
};
|
|
||||||
client.rooms.add(this.id);
|
|
||||||
this.clients.set(client.id, client);
|
|
||||||
termoutput && term.green("Client Room joined ").white(this.name," in ").yellow(this.clients.size + "").white(" clients")('\n');
|
|
||||||
};
|
|
||||||
Room.prototype.down = function(){
|
|
||||||
termoutput && term.red("Room is downed ").red(this.name," in ").yellow(this.clients.size + "").red(" clients")('\n');
|
|
||||||
this.send([{
|
|
||||||
roomid: this.id,
|
|
||||||
ownerid: this.owner.id
|
|
||||||
},'room/closed']);
|
|
||||||
Room.rooms.delete(this.id);
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* @param {Client} client
|
|
||||||
*/
|
|
||||||
Room.prototype.eject = function(client){
|
|
||||||
if(this.notifyActionEjected)
|
|
||||||
{
|
|
||||||
this.send(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
id: client.id,
|
|
||||||
roomid: this.id,
|
|
||||||
ownerid: this.owner.id
|
|
||||||
},
|
|
||||||
'room/ejected'
|
|
||||||
],
|
|
||||||
client.id,
|
|
||||||
client => client.peerInfoNotifiable()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
client.rooms.delete(this.id);
|
|
||||||
this.clients.delete(client.id);
|
|
||||||
|
|
||||||
if(this.clients.size == 0)
|
|
||||||
{
|
|
||||||
this.down();
|
|
||||||
termoutput && term.red("Client Room closed ").red(this.name," at 0 clients")('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
termoutput && term.red("Client Room ejected ").red(this.name," in ").yellow(this.clients.size + "").red(" clients")('\n');
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {Map<string, Room>}
|
|
||||||
*/
|
|
||||||
Room.rooms = new Map();
|
|
||||||
|
|
||||||
on('connect', (client) => {
|
|
||||||
let room = new Room();
|
|
||||||
room.accessType = "private";
|
|
||||||
room.joinType = "notify";
|
|
||||||
room.description = 'Private room';
|
|
||||||
room.id = client.id;
|
|
||||||
room.name = "Your Room | " + client.id;
|
|
||||||
room.owner = client;
|
|
||||||
room.publish();
|
|
||||||
room.join(client);
|
|
||||||
});
|
|
||||||
|
|
||||||
on('disconnect', (client) => {
|
|
||||||
const room = Room.rooms.get(client.id);
|
|
||||||
if (room) room.eject(client);
|
|
||||||
for (const roomId of client.rooms) {
|
|
||||||
const r = Room.rooms.get(roomId);
|
|
||||||
if (r) r.eject(client);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
let CreateRoomVerify = joi.object({
|
|
||||||
type: joi.any().required(),
|
|
||||||
accessType: joi.string().pattern(/^public$|private$/).required(),
|
|
||||||
notifyActionInvite: joi.boolean().required(),
|
|
||||||
notifyActionJoined: joi.boolean().required(),
|
|
||||||
notifyActionEjected: joi.boolean().required(),
|
|
||||||
joinType: joi.string().pattern(/^free$|^invite$|^password$|^lock$/).required(),
|
|
||||||
description: joi.string().required(),
|
|
||||||
name: joi.string().required(),
|
|
||||||
credential: joi.string().optional(),
|
|
||||||
ifexistsJoin: joi.boolean().optional(),
|
|
||||||
autoFetchInfo: joi.boolean().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
register('myroom-info', (client, msg) => {
|
|
||||||
let room = Room.rooms.get(client.id);
|
|
||||||
return { status: "success", room: room.toJSON() };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('room-peers', (client, msg) => {
|
|
||||||
const { roomId, filter } = msg;
|
|
||||||
if (!Room.rooms.has(roomId)) {
|
|
||||||
return { status: 'fail' };
|
|
||||||
}
|
|
||||||
const filteredPeers = Room.rooms.get(roomId).filterPeers(filter || {});
|
|
||||||
return { status: 'success', peers: filteredPeers.map(i => i.id) };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('room/peer-count', (client, msg) => {
|
|
||||||
const { roomId, filter } = msg;
|
|
||||||
if (!Room.rooms.has(roomId)) {
|
|
||||||
return { status: 'fail' };
|
|
||||||
}
|
|
||||||
const filteredPeers = Room.rooms.get(roomId).filterPeers(filter || {});
|
|
||||||
return { status: 'success', count: filteredPeers.length };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('room-info', (client, msg) => {
|
|
||||||
const { name } = msg;
|
|
||||||
for (const [roomId, room] of Room.rooms) {
|
|
||||||
if (name == room.name) {
|
|
||||||
return { status: "success", room: room.toJSON() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { status: "fail", message: "NOT-FOUND-ROOM" };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('joinedrooms', (client, msg) => {
|
|
||||||
return [...client.rooms].map(e => Room.rooms.get(e).toJSON());
|
|
||||||
});
|
|
||||||
|
|
||||||
register('closeroom', (client, msg) => {
|
|
||||||
const { roomId } = msg;
|
|
||||||
if (!Room.rooms.has(roomId)) {
|
|
||||||
return { status: 'fail' };
|
|
||||||
}
|
|
||||||
const room = Room.rooms.get(roomId);
|
|
||||||
if (room.owner === client.id) {
|
|
||||||
room.down();
|
|
||||||
return { status: 'success' };
|
|
||||||
}
|
|
||||||
return { status: 'fail' };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('create-room', (client, msg) => {
|
|
||||||
const { error } = CreateRoomValidate.validate(msg);
|
|
||||||
if (error) {
|
|
||||||
return { status: 'fail', messages: error.message };
|
|
||||||
}
|
|
||||||
|
|
||||||
const { name } = msg;
|
|
||||||
for (const [, room] of Room.rooms) {
|
|
||||||
if (name == room.name) {
|
|
||||||
return { status: "fail", message: "ALREADY-EXISTS" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let room = new Room();
|
|
||||||
room.accessType = msg.accessType;
|
|
||||||
room.notifyActionInvite = msg.notifyActionInvite;
|
|
||||||
room.notifyActionJoined = msg.notifyActionJoined;
|
|
||||||
room.notifyActionEjected = msg.notifyActionEjected;
|
|
||||||
room.joinType = msg.joinType;
|
|
||||||
room.description = msg.description;
|
|
||||||
room.name = msg.name;
|
|
||||||
room.owner = client;
|
|
||||||
|
|
||||||
if (msg.credential) {
|
|
||||||
room.credential = Sha256(msg.credential + "");
|
|
||||||
}
|
|
||||||
|
|
||||||
room.publish();
|
|
||||||
room.join(client);
|
|
||||||
|
|
||||||
return { status: "success", room: room.toJSON() };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('joinroom', (client, msg) => {
|
|
||||||
const { name, autoFetchInfo } = msg;
|
|
||||||
let roomId;
|
|
||||||
|
|
||||||
for (const [_roomId, room] of Room.rooms) {
|
|
||||||
if (name == room.name) {
|
|
||||||
roomId = _roomId;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Room.rooms.has(roomId)) {
|
|
||||||
return { status: "fail", message: "NOT-FOUND-ROOM" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const room = Room.rooms.get(roomId);
|
|
||||||
|
|
||||||
if (room.joinType == "lock") {
|
|
||||||
return { status: "fail", message: "LOCKED-ROOM" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (room.joinType == "password") {
|
|
||||||
if (room.credential == Sha256(msg.credential + "")) {
|
|
||||||
let info = {};
|
|
||||||
if (autoFetchInfo) {
|
|
||||||
info.info = room.getInfo();
|
|
||||||
}
|
|
||||||
room.join(client);
|
|
||||||
return { status: "success", room: room.toJSON(), ...info };
|
|
||||||
}
|
|
||||||
return { status: "fail", message: "WRONG-PASSWORD", area: "credential" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (room.joinType == "free") {
|
|
||||||
let info = {};
|
|
||||||
if (autoFetchInfo) {
|
|
||||||
info.info = room.getInfo();
|
|
||||||
}
|
|
||||||
room.join(client);
|
|
||||||
return { status: "success", room: room.toJSON(), ...info };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (room.joinType == "invite") {
|
|
||||||
room.waitingInvited.add(client.id);
|
|
||||||
if (room.notifyActionInvite) {
|
|
||||||
room.send([{ id: client.id }, "room/invite"]);
|
|
||||||
} else {
|
|
||||||
room.owner.send([{ id: client.id }, "room/invite"]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { status: "fail", message: "NOT-FOUND-ROOM" };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('ejectroom', (client, msg) => {
|
|
||||||
const { roomId } = msg;
|
|
||||||
|
|
||||||
if (!Room.rooms.has(roomId)) {
|
|
||||||
return { status: "fail", message: "NOT-FOUND-ROOM" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const room = Room.rooms.get(roomId);
|
|
||||||
|
|
||||||
if (!room.clients.has(client.id)) {
|
|
||||||
return { status: "fail", message: "ALREADY-ROOM-OUT" };
|
|
||||||
}
|
|
||||||
|
|
||||||
room.eject(client);
|
|
||||||
return { status: "success" };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('accept/invite-room', (client, msg) => {
|
|
||||||
const { roomId, clientId } = msg;
|
|
||||||
|
|
||||||
if (!Room.rooms.has(roomId)) {
|
|
||||||
return { status: "fail", message: "NOT-FOUND-ROOM" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const room = Room.rooms.get(roomId);
|
|
||||||
|
|
||||||
if (!client.rooms.has(room.id)) {
|
|
||||||
return { status: "fail", message: "FORBIDDEN-INVITE-ACTIONS" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (room.joinType == 'invite') {
|
|
||||||
return { status: "fail", message: "INVALID-DATA" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!room.waitingInvited.includes(clientId)) {
|
|
||||||
return { status: "fail", message: "NO-WAITING-INVITED" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Client.clients.has(clientId)) {
|
|
||||||
return { status: "fail", message: "NO-CLIENT" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const JoinClient = Client.clients.get(clientId);
|
|
||||||
room.join(JoinClient);
|
|
||||||
JoinClient.send([{ status: "accepted" }, 'room/invite/status']);
|
|
||||||
|
|
||||||
return { status: "success" };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('reject/invite-room', (client, msg) => {
|
|
||||||
const { roomId, clientId } = msg;
|
|
||||||
|
|
||||||
if (!Room.rooms.has(roomId)) {
|
|
||||||
return { status: "fail", message: "NOT-FOUND-ROOM" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const room = Room.rooms.get(roomId);
|
|
||||||
|
|
||||||
if (!client.rooms.has(room.id)) {
|
|
||||||
return { status: "fail", message: "FORBIDDEN-INVITE-ACTIONS" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (room.joinType == 'invite') {
|
|
||||||
return { status: "fail", message: "INVALID-DATA" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!room.waitingInvited.includes(clientId)) {
|
|
||||||
return { status: "fail", message: "NO-WAITING-INVITED" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Client.clients.has(clientId)) {
|
|
||||||
return { status: "fail", message: "NO-CLIENT" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const JoinClient = Client.clients.get(clientId);
|
|
||||||
room.waitingInvited = room.waitingInvited.filter(e => e != clientId);
|
|
||||||
room.send([{ id: clientId, roomId: room.id }, 'room/invite/status']);
|
|
||||||
JoinClient.send([{ status: "rejected" }, 'room/invite/status']);
|
|
||||||
|
|
||||||
return { status: "success" };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('room/list', (client, msg) => {
|
|
||||||
const rooms = [];
|
|
||||||
for (const [id, room] of Room.rooms) {
|
|
||||||
if (room.accessType == "public") {
|
|
||||||
rooms.push({
|
|
||||||
name: room.name,
|
|
||||||
joinType: room.joinType,
|
|
||||||
description: room.description,
|
|
||||||
id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { type: 'public/rooms', rooms };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('room/info', (client, msg) => {
|
|
||||||
const { roomId, name } = msg;
|
|
||||||
|
|
||||||
if (!Room.rooms.has(roomId)) {
|
|
||||||
return { status: "fail", message: "NOT-FOUND-ROOM" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const room = Room.rooms.get(roomId);
|
|
||||||
|
|
||||||
if (!client.rooms.has(room.id)) {
|
|
||||||
return { status: "fail", message: "NO-JOINED-ROOM" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (name) {
|
|
||||||
return { status: "success", value: room.info.get(name) };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { status: "success", value: room.getInfo() };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('room/setinfo', (client, msg) => {
|
|
||||||
const { roomId, name, value } = msg;
|
|
||||||
|
|
||||||
if (!Room.rooms.has(roomId)) {
|
|
||||||
return { status: "fail", message: "NOT-FOUND-ROOM" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const room = Room.rooms.get(roomId);
|
|
||||||
|
|
||||||
if (!client.rooms.has(room.id)) {
|
|
||||||
return { status: "fail", message: "NO-JOINED-ROOM" };
|
|
||||||
}
|
|
||||||
|
|
||||||
room.info.set(name, value);
|
|
||||||
|
|
||||||
room.send(
|
|
||||||
[{ name, value, roomId: room.id }, "room/info"],
|
|
||||||
client.id,
|
|
||||||
c => c.roomInfoNotifiable()
|
|
||||||
);
|
|
||||||
|
|
||||||
return { status: "success" };
|
|
||||||
});
|
|
||||||
|
|
||||||
exports.Room = Room;
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const { on, emit, register } = require("../WebSocket");
|
|
||||||
|
|
||||||
const defaults = {
|
|
||||||
notifyPairInfo: true,
|
|
||||||
packrecaive: true,
|
|
||||||
packsending: true,
|
|
||||||
notifyRoomInfo: true
|
|
||||||
};
|
|
||||||
|
|
||||||
on('connect', (client) => {
|
|
||||||
for (const [name, value] of Object.entries(defaults)) {
|
|
||||||
client.store.set(name, value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
register('connection/pairinfo', (client, msg) => {
|
|
||||||
client.store.set("notifyPairInfo", !!msg.value);
|
|
||||||
return { status: 'success' };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('connection/roominfo', (client, msg) => {
|
|
||||||
client.store.set("notifyRoomInfo", !!msg.value);
|
|
||||||
return { status: 'success' };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('connection/packrecaive', (client, msg) => {
|
|
||||||
client.store.set("packrecaive", !!msg.value);
|
|
||||||
return { status: 'success' };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('connection/packsending', (client, msg) => {
|
|
||||||
client.store.set("packsending", !!msg.value);
|
|
||||||
return { status: 'success' };
|
|
||||||
});
|
|
||||||
|
|
||||||
register('connection/reset', (client, msg) => {
|
|
||||||
for (const [name, value] of Object.entries(defaults)) {
|
|
||||||
client.store.set(name, value);
|
|
||||||
}
|
|
||||||
return { status: 'success' };
|
|
||||||
});
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const { on } = require("../WebSocket");
|
|
||||||
|
|
||||||
on('connect', (client) => {
|
|
||||||
client.send([{ type: 'id', value: client.id }, 'id']);
|
|
||||||
});
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
"use strict";
|
|
||||||
|
|
||||||
let websocket = require("websocket");
|
|
||||||
let http = null;
|
|
||||||
let wsServer = null;
|
|
||||||
let {randomUUID} = require("crypto");
|
|
||||||
const { Client } = require("./Client.js");
|
|
||||||
const { termoutput } = require("./config");
|
|
||||||
const EventEmitter = require("./EventEmitter");
|
|
||||||
const MessageRouter = require("./MessageRouter");
|
|
||||||
|
|
||||||
function init(server) {
|
|
||||||
http = server;
|
|
||||||
|
|
||||||
termoutput && console.log("Web Socket Protocol is ready");
|
|
||||||
|
|
||||||
http.addListener("upgrade", () => {
|
|
||||||
termoutput && console.log("HTTP Upgrading to WebSocket");
|
|
||||||
});
|
|
||||||
|
|
||||||
wsServer = new websocket.server({
|
|
||||||
httpServer: http,
|
|
||||||
autoAcceptConnections: true
|
|
||||||
});
|
|
||||||
|
|
||||||
wsServer.addListener("connect", (socket) => {
|
|
||||||
let client = new Client();
|
|
||||||
let id = randomUUID();
|
|
||||||
socket.id = id;
|
|
||||||
client.id = id;
|
|
||||||
client.socket = socket;
|
|
||||||
client.created_at = new Date();
|
|
||||||
Client.clients.set(id, client);
|
|
||||||
|
|
||||||
EventEmitter.emit('connect', client);
|
|
||||||
|
|
||||||
let pingTimer = setInterval(() => socket.ping('saQut'), 10_000);
|
|
||||||
|
|
||||||
socket.addListener("pong", (validationText) => {
|
|
||||||
if (validationText.toString('utf8') != "saQut") {
|
|
||||||
socket.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.addListener("message", ({ type, utf8Data }) => {
|
|
||||||
if (type == "utf8") {
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(utf8Data);
|
|
||||||
const [message, id, action] = json;
|
|
||||||
|
|
||||||
let response;
|
|
||||||
|
|
||||||
if (typeof id === 'number' || typeof id === 'string') {
|
|
||||||
response = MessageRouter.handle(client, message);
|
|
||||||
|
|
||||||
if (action === 'R') {
|
|
||||||
client.send([response, id, 'E']);
|
|
||||||
} else if (action === 'S') {
|
|
||||||
client.send([response, id, 'C']);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const result = MessageRouter.handle(client, message);
|
|
||||||
|
|
||||||
if (result && result.broadcast) {
|
|
||||||
EventEmitter.emit('broadcast', result.broadcast, client);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
EventEmitter.emit('messageError', client, utf8Data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.addListener("close", () => {
|
|
||||||
EventEmitter.emit('disconnect', client);
|
|
||||||
Client.clients.delete(id);
|
|
||||||
clearInterval(pingTimer);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const on = EventEmitter.on;
|
|
||||||
const emit = EventEmitter.emit;
|
|
||||||
const off = EventEmitter.off;
|
|
||||||
|
|
||||||
const register = MessageRouter.register;
|
|
||||||
const handle = MessageRouter.handle;
|
|
||||||
|
|
||||||
exports.init = init;
|
|
||||||
exports.on = on;
|
|
||||||
exports.emit = emit;
|
|
||||||
exports.off = off;
|
|
||||||
exports.register = register;
|
|
||||||
exports.handle = handle;
|
|
||||||
235
Source/api.js
235
Source/api.js
|
|
@ -1,235 +0,0 @@
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const express = require("express");
|
|
||||||
const router = express.Router();
|
|
||||||
const { Client } = require("./Client");
|
|
||||||
const { Room } = require("./Services/Room");
|
|
||||||
|
|
||||||
const apiKeys = new Map();
|
|
||||||
const webhooks = new Map();
|
|
||||||
|
|
||||||
function auth(req, res, next) {
|
|
||||||
const key = req.headers['x-api-key'];
|
|
||||||
|
|
||||||
if (!key) {
|
|
||||||
return res.status(401).json({ status: 'fail', message: 'API_KEY_REQUIRED' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!apiKeys.has(key)) {
|
|
||||||
return res.status(401).json({ status: 'fail', message: 'INVALID_API_KEY' });
|
|
||||||
}
|
|
||||||
|
|
||||||
req.server = apiKeys.get(key);
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
||||||
router.post('/auth/key', (req, res) => {
|
|
||||||
const { domain } = req.body;
|
|
||||||
|
|
||||||
if (!domain) {
|
|
||||||
return res.json({ status: 'fail', message: 'DOMAIN_REQUIRED' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = require("crypto").randomUUID();
|
|
||||||
apiKeys.set(key, { domain, key });
|
|
||||||
|
|
||||||
res.json({ status: 'success', key });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/client/:id/send', auth, (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { pack } = req.body;
|
|
||||||
|
|
||||||
const client = Client.clients.get(id);
|
|
||||||
|
|
||||||
if (!client) {
|
|
||||||
return res.json({ status: 'fail', message: 'CLIENT_NOT_FOUND' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!pack) {
|
|
||||||
return res.json({ status: 'fail', message: 'PACK_REQUIRED' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const fromServer = req.server;
|
|
||||||
client.send([{ from: 'server', fromServer, pack }, 'server/pack']);
|
|
||||||
|
|
||||||
res.json({ status: 'success' });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/room/:id/send', auth, (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { pack, wom } = req.body;
|
|
||||||
|
|
||||||
const room = Room.rooms.get(id);
|
|
||||||
|
|
||||||
if (!room) {
|
|
||||||
return res.json({ status: 'fail', message: 'ROOM_NOT_FOUND' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!pack) {
|
|
||||||
return res.json({ status: 'fail', message: 'PACK_REQUIRED' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const fromServer = req.server;
|
|
||||||
|
|
||||||
room.send(
|
|
||||||
[{ from: 'server', fromServer, pack, roomId: id }, 'server/pack/room'],
|
|
||||||
wom ? undefined : 'server',
|
|
||||||
() => true
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({ status: 'success' });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/room/create', auth, (req, res) => {
|
|
||||||
const { name, accessType, joinType, description, credential } = req.body;
|
|
||||||
|
|
||||||
if (!name) {
|
|
||||||
return res.json({ status: 'fail', message: 'NAME_REQUIRED' });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [, room] of Room.rooms) {
|
|
||||||
if (room.name === name) {
|
|
||||||
return res.json({ status: 'fail', message: 'ROOM_ALREADY_EXISTS' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const room = new Room();
|
|
||||||
room.name = name;
|
|
||||||
room.accessType = accessType || 'public';
|
|
||||||
room.joinType = joinType || 'free';
|
|
||||||
room.description = description || '';
|
|
||||||
room.owner = { id: 'server', isServer: true };
|
|
||||||
|
|
||||||
if (credential) {
|
|
||||||
const { createHash } = require("crypto");
|
|
||||||
room.credential = createHash("sha256").update(credential).digest("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
room.publish();
|
|
||||||
|
|
||||||
res.json({ status: 'success', room: room.toJSON() });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/room/:id/join', auth, (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { credential } = req.body;
|
|
||||||
|
|
||||||
const room = Room.rooms.get(id);
|
|
||||||
|
|
||||||
if (!room) {
|
|
||||||
return res.json({ status: 'fail', message: 'ROOM_NOT_FOUND' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (room.joinType === 'lock') {
|
|
||||||
return res.json({ status: 'fail', message: 'ROOM_LOCKED' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (room.joinType === 'password') {
|
|
||||||
const { createHash } = require("crypto");
|
|
||||||
const hash = createHash(credential || '').digest("hex");
|
|
||||||
if (room.credential !== hash) {
|
|
||||||
return res.json({ status: 'fail', message: 'WRONG_PASSWORD' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fakeClient = {
|
|
||||||
id: 'server-joined',
|
|
||||||
isServer: true,
|
|
||||||
rooms: new Set(),
|
|
||||||
send: (msg) => {
|
|
||||||
const fromServer = req.server;
|
|
||||||
room.send([{ from: 'server', fromServer, ...msg[0] }, msg[1]], 'server');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
room.join(fakeClient);
|
|
||||||
|
|
||||||
res.json({ status: 'success', room: room.toJSON() });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete('/room/:id/leave', auth, (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
const room = Room.rooms.get(id);
|
|
||||||
|
|
||||||
if (!room) {
|
|
||||||
return res.json({ status: 'fail', message: 'ROOM_NOT_FOUND' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const fakeClient = { id: 'server-joined', isServer: true };
|
|
||||||
room.eject(fakeClient);
|
|
||||||
|
|
||||||
res.json({ status: 'success' });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/room/:id', (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
const room = Room.rooms.get(id);
|
|
||||||
|
|
||||||
if (!room) {
|
|
||||||
return res.json({ status: 'fail', message: 'ROOM_NOT_FOUND' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ status: 'success', room: room.toJSON() });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/rooms', (req, res) => {
|
|
||||||
const rooms = [];
|
|
||||||
|
|
||||||
for (const [id, room] of Room.rooms) {
|
|
||||||
rooms.push({
|
|
||||||
id,
|
|
||||||
name: room.name,
|
|
||||||
accessType: room.accessType,
|
|
||||||
joinType: room.joinType,
|
|
||||||
description: room.description,
|
|
||||||
clientCount: room.clients.size
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ status: 'success', rooms });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/clients', (req, res) => {
|
|
||||||
const clients = [];
|
|
||||||
|
|
||||||
for (const [id, client] of Client.clients) {
|
|
||||||
clients.push({
|
|
||||||
id,
|
|
||||||
rooms: [...client.rooms],
|
|
||||||
pairs: [...client.pairs]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ status: 'success', clients });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/webhook', auth, (req, res) => {
|
|
||||||
const { url, events } = req.body;
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
return res.json({ status: 'fail', message: 'URL_REQUIRED' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = req.server;
|
|
||||||
webhooks.set(server.domain, { url, events: events || ['server/pack', 'server/pack/room'] });
|
|
||||||
|
|
||||||
res.json({ status: 'success' });
|
|
||||||
});
|
|
||||||
|
|
||||||
function triggerWebhook(event, data) {
|
|
||||||
for (const [, webhook] of webhooks) {
|
|
||||||
if (webhook.events.includes(event)) {
|
|
||||||
fetch(webhook.url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ event, data })
|
|
||||||
}).catch(console.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
module.exports.triggerWebhook = triggerWebhook;
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
exports.termoutput = false;
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
require("./HTTPServer.js");
|
|
||||||
|
|
||||||
const { http } = require("./HTTPServer");
|
|
||||||
|
|
||||||
const WebSocket = require("./WebSocket");
|
|
||||||
WebSocket.init(http);
|
|
||||||
|
|
||||||
require("./Services/YourID.js");
|
|
||||||
require("./Services/Auth.js");
|
|
||||||
require("./Services/Room.js");
|
|
||||||
require("./Services/DataTransfer.js");
|
|
||||||
require("./Services/IPPressure.js");
|
|
||||||
require("./Services/Session.js");
|
|
||||||
|
|
||||||
process.on('unhandledRejection',(reason, promise)=>{
|
|
||||||
console.log("Process unhandledRejection",{reason, promise})
|
|
||||||
});
|
|
||||||
process.on('rejectionHandled',(promise)=>{
|
|
||||||
console.log("Process rejectionHandled",{promise})
|
|
||||||
});
|
|
||||||
process.on('multipleResolves',(type, promise, value)=>{
|
|
||||||
console.log("Process multipleResolves",{type, promise, value})
|
|
||||||
});
|
|
||||||
process.on('warning',(err)=>{
|
|
||||||
console.log("Process warning", err)
|
|
||||||
});
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
# 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ı:**~~ **DÜZELTİLDİ (#33)** — aşağıdaki §"#33" bölümüne bakın. Eskiden generic dispatcher action 'R' için hemen `[null,id,'E']` yanıtlıyor, eşin asıl cevabını (response/to) eziyordu. Artık handler `nil` dönerse dispatcher yanıt göndermez.
|
||||||
|
- **`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ı).
|
||||||
|
|
||||||
|
## Ölçek ayarı & leak sağlamlaştırma (yüksek bağlantı + bitmek bilmeyen trafik)
|
||||||
|
|
||||||
|
Hedef: çok yüksek bağlantı sayısı + sürekli mesaj trafiği; limitleri/poolları yüksek tut, belleği bol kullan ama **leak yok**.
|
||||||
|
|
||||||
|
6. **Yapılandırılabilir, yüksek limitler** (`internal/config` `ConnConfig`, env ile):
|
||||||
|
- `MWSE_OUTBOUND_BUFFER` (varsayılan **1024**) — bağlantı başına gönderim kuyruğu. Bellek notu: kanal ~24 B/slot önceden ayrılır → ~24 KiB/bağlantı (100k bağlantıda ~2.4 GiB boştayken). Düşük bellekli host'ta düşür, burst'lü üreticide yükselt.
|
||||||
|
- `MWSE_MAX_MESSAGE_SIZE` (varsayılan **16 MiB**) — büyük tünel payload'ları (#30) için yüksek.
|
||||||
|
- `MWSE_READ_BUFFER` / `MWSE_WRITE_BUFFER`, `MWSE_PING_INTERVAL`, `MWSE_PONG_WAIT`, `MWSE_WRITE_WAIT`.
|
||||||
|
7. **gorilla `WriteBufferPool` (paylaşımlı `sync.Pool`)** — yazma scratch buffer'ları bağlantılar arasında yeniden kullanılır; yüksek bağlantı sayısında büyük bellek tasarrufu, leak yok (GC yönetir). 150 bağlantı + ağır trafikte RSS ~43 MB ölçüldü.
|
||||||
|
8. **Dolu buffer'da düşür-ama-kapatma** (bkz. #5): yüksek buffer + bu politika → relay teslimat %98.5'ten **%99.98'e** çıktı (150 bağlantıda 630974 gönderim, 99 düşüş).
|
||||||
|
|
||||||
|
### Leak düzeltmeleri (sınırsız büyümeyi önle)
|
||||||
|
9. **Pairing ters-indeksi (`pairedBy`).** Önceki halde tek-yönlü bekleyen istekler (hedef yanıt vermeden koparsa) uzun ömürlü istemcinin `pairs` setinde **kalıcı çöp** bırakıyordu → churn altında sınırsız büyüme. Çözüm: her istemci hem giden (`pairs`) hem gelen (`pairedBy`) kenarları tutar; `AddPair/RemovePair` iki tarafı da günceller (kilitler iç içe değil → deadlock yok); disconnect'te `ForgetPeer` ile X'e değen TÜM kenarlar **O(derece)** temizlenir (tüm istemcileri taramadan).
|
||||||
|
10. **Davet bekleme listesi temizliği.** İstemci `waiting` (beklediği oda id'leri) tutar; disconnect'te ilgili odaların `waitingInvited` setinden düşülür → ölü id birikmez.
|
||||||
|
11. **`realloc` artık farklı adres verir.** Node "önce release sonra lock" yapıyordu → tek istemcide aynı adresi döndürüyordu (işlevsiz). Düzeltme: **önce yeni adresi al, sonra eskiyi bırak** (eski rezerve kaldığından yeni mutlaka farklı); adres alanı tükenmişse eskiyi koru, fail dön.
|
||||||
|
12. **Goroutine sızıntısı yok:** writePump/pingLoop/readLoop hepsi `done`/conn-close ile çıkar; gerçekten ölü peer write-deadline ile temizlenir.
|
||||||
|
|
||||||
|
### #27 paritesi — eklenen
|
||||||
|
13. **`ifexistsJoin`:** create-room'da isim çakışırsa hata yerine mevcut odaya katıl (tek tur "create or join").
|
||||||
|
|
||||||
|
## #33 — EventPool WOM (askıda kalan promise) düzeltmesi
|
||||||
|
|
||||||
|
**Kök neden:** İki yönlü hata. (a) Engine dispatcher, numeric-id + action 'R' olan HER frame'e anında `[result, id, 'E']` yanıtlıyordu — handler `nil` dönse bile `[null,id,'E']`. (b) SDK `EventPool.request()` WOM (fire-and-forget) paketler için bile bir waiter (promise) kaydediyordu. Sonuç: `request/to` gibi cevabı **out-of-band** (`response/to`) gelen paketlerde, sahte `[null,id,'E']` waiter'ı erken çözüp **siliyor**, gerçek cevap kayboluyordu; saf WOM paketlerde ise (ör. `pack/to`) gereksiz waiter kalıyordu.
|
||||||
|
|
||||||
|
**Çözüm:**
|
||||||
|
14. **Engine:** dispatcher artık handler `nil` döndürürse yanıt göndermez (`server.go`). `nil` = "yanıtım yok / cevap out-of-band gelecek". Tüm gerçek request handler'ları non-nil döner (reply alır); yalnızca relay/WOM handler'ları (`pack/to` handshake'siz, `request/to`, `response/to`, `pack/room` handshake'siz) nil döner ve sessiz kalır. Bu, tel **şekillerini** değiştirmez; sadece SDK'nın kullanamadığı sahte frame'i kaldırır.
|
||||||
|
15. **SDK:** `EventPool.only(msg)` eklendi — WOM yolu, waiter bırakmaz. `Peer.send` (pack/to) ve `Room.send` handshake'siz dalı artık `only()` kullanır. `request()` yalnızca cevap bekleyen paketlere ayrıldı. Public API (peer.send/room.send imzaları) değişmedi.
|
||||||
|
|
||||||
|
**Regression:** `internal/ws` `TestServerNoReplyOnNilResult`; `internal/services` `TestRequestResponseRoundTrip` (out-of-band cevap aynı id ile geri geliyor, erken yanıt yok).
|
||||||
|
|
||||||
|
## Data-sync alt sistemleri (#43 / #44 / #45)
|
||||||
|
|
||||||
|
16. **`services.Register` artık `*Registry` döndürür ve `...Option` alır.** Eski çağrılar (`Register(hub)`) dönüşü yok sayarak çalışmaya devam eder. Registry, ömür yönetimi gereken store'ları (notify, datastore) main'e açar; main bunların janitor'larını başlatır ve shutdown'da durdurur (goroutine sızıntısı yok). 3. taraf entegrasyonları (#44 trigger) `WithNotifyTrigger` ile enjekte edilir; varsayılan no-op (IPPressure'daki `Announcer` deseniyle aynı).
|
||||||
|
|
||||||
|
17. **#43 Notify (store-and-forward) + #44 Suit:** `internal/notify` saf, transport-bağımsız store (enjekte edilebilir clock). Hedef offline ise mesaj kuyruğa girer, bağlanınca `notify` sinyali ile teslim edilir; her bildirimde `trace` id → `notify/status` ile sorgulanır. **Leak yok:** her bildirimde TTL (vars. 24s) + hedef başına sınır (vars. 1024, en eski düşer) + periyodik janitor. **Suit (#44):** `suit:true` bildirime client `notify/reply` ile cevap verir; cevap 3. taraf sunucuya `NotifyTrigger` ile **manuel tetiklenir** (poll yok) ve origin client online ise `notify/reply` sinyali ile bildirilir.
|
||||||
|
|
||||||
|
18. **#45 Datastore + Active/Passive Sync:** `internal/datastore` saf paket (ws bağımlılığı yok — data kurallarını hub/socket olmadan test edilebilir kılar; servis katmanı abone id'lerini çözüp sinyal atar).
|
||||||
|
- **Active sync / collection (CRUD broadcast):** `data/open`/`data/set`/`data/delete`/`data/get`. Sunucu otoriter kopyayı tutar; her mutasyon datastore kilidi altında **monoton seq** ile damgalanır → **çakışma çözümü arrival-time** (kilidi en son alan kazanır), seq her broadcast'te taşınır, client'lar yakınsar. Set/delete diğer abonelere `data/op` sinyali ile yayılır (origin hariç; o seq'i kendi yanıtından öğrenir).
|
||||||
|
- **Passive sync (merge pool):** `sync/open`/`sync/push`/`sync/pull`. Öğeler kanonik JSON hash'iyle **dedupe** edilir; push yalnızca yeni delta'yı `sync/add` ile yayar → ortak havuzda toplanır, hepsi eşit olana kadar push/pull sürer.
|
||||||
|
- **Datastore tipi:** `kind:"temp"|"permanent"` (alan adı bilinçli olarak `kind`; `type` WSTS handler seçici olduğu için kullanılamaz). temp TTL ile expire, permanent kalıcı. id boşsa public id üretilir.
|
||||||
|
- **Leak yok:** temp store/pool TTL + janitor; disconnect'te `UnsubscribeAll` ile abonelik kalıntısı bırakılmaz.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
import MWSE from "frontend";
|
|
||||||
|
|
||||||
export interface IConnection{
|
|
||||||
endpoint: string;
|
|
||||||
autoReconnect?: boolean | {
|
|
||||||
timeout: number;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export class Connection
|
|
||||||
{
|
|
||||||
public ws! : WebSocket;
|
|
||||||
public endpoint : URL;
|
|
||||||
public autoPair : boolean = false;
|
|
||||||
public connected : boolean = false;
|
|
||||||
|
|
||||||
public autoReconnect : boolean = true;
|
|
||||||
public autoReconnectTimeout : number = 3000;
|
|
||||||
public autoReconnectTimer? : number;
|
|
||||||
constructor(mwse:MWSE, options: IConnection){
|
|
||||||
|
|
||||||
if(options.endpoint == "auto")
|
|
||||||
{
|
|
||||||
const RootURL : string = ( <HTMLScriptElement> document.currentScript).src
|
|
||||||
let scriptPath = new URL(RootURL);
|
|
||||||
let isSecurity = scriptPath.protocol == "https:";
|
|
||||||
let dumeUrl = scriptPath.pathname.split('/').slice(0,-1).join('/') + '/';
|
|
||||||
let wsSocket = new URL(dumeUrl, scriptPath);
|
|
||||||
wsSocket.protocol = isSecurity ? 'wss:' : 'ws:';
|
|
||||||
this.endpoint = new URL(wsSocket.href);
|
|
||||||
}else{
|
|
||||||
try{
|
|
||||||
// Testing
|
|
||||||
this.endpoint = new URL(options.endpoint);
|
|
||||||
}catch{
|
|
||||||
throw new Error("endpoint is required")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(typeof options.autoReconnect == "boolean")
|
|
||||||
{
|
|
||||||
this.autoReconnect = true;
|
|
||||||
}else if(options.autoReconnect)
|
|
||||||
{
|
|
||||||
this.autoReconnect = true;
|
|
||||||
this.autoReconnectTimeout = options.autoReconnect.timeout;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public connect()
|
|
||||||
{
|
|
||||||
if(this.autoReconnectTimer)
|
|
||||||
{
|
|
||||||
clearTimeout(this.autoReconnectTimer)
|
|
||||||
};
|
|
||||||
this.ws = new WebSocket(this.endpoint.href);
|
|
||||||
this.addWSEvents();
|
|
||||||
}
|
|
||||||
public disconnect()
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Eğer bilinerek elle kapatıldıysa otomatik tekrar bağlanmasının
|
|
||||||
* önüne geçmek için autoReconnect bayrağını her zaman kapalı tutmak gerekir
|
|
||||||
*/
|
|
||||||
this.autoReconnect = false;
|
|
||||||
this.ws.close();
|
|
||||||
}
|
|
||||||
public addWSEvents()
|
|
||||||
{
|
|
||||||
this.ws.addEventListener("open", () => this.eventOpen());
|
|
||||||
this.ws.addEventListener("close", () => this.eventClose());
|
|
||||||
this.ws.addEventListener("error", () => this.eventError());
|
|
||||||
this.ws.addEventListener("message", ({data}) => this.eventMessage(data as string | ArrayBuffer));
|
|
||||||
}
|
|
||||||
private eventOpen()
|
|
||||||
{
|
|
||||||
this.connected = true;
|
|
||||||
for (const callback of this.activeConnectionEvent) {
|
|
||||||
callback(void 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private eventClose()
|
|
||||||
{
|
|
||||||
for (const callback of this.passiveConnectionEvent) {
|
|
||||||
callback(void 0);
|
|
||||||
}
|
|
||||||
this.connected = false;
|
|
||||||
if(this.autoReconnect)
|
|
||||||
{
|
|
||||||
this.autoReconnectTimer = setTimeout(() => this.connect(), this.autoReconnectTimeout) as unknown as number;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private eventError()
|
|
||||||
{
|
|
||||||
this.connected = false;
|
|
||||||
}
|
|
||||||
private recaivePackEvent : ((data:any) => any)[] = [];
|
|
||||||
public onRecaivePack(func:(data:any) => any)
|
|
||||||
{
|
|
||||||
this.recaivePackEvent.push(func);
|
|
||||||
}
|
|
||||||
private activeConnectionEvent : Function[] = [];
|
|
||||||
public onActive(func:Function)
|
|
||||||
{
|
|
||||||
if(this.connected)
|
|
||||||
{
|
|
||||||
func()
|
|
||||||
}else{
|
|
||||||
this.activeConnectionEvent.push(func);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private passiveConnectionEvent : Function[] = [];
|
|
||||||
public onPassive(func:Function)
|
|
||||||
{
|
|
||||||
if(!this.connected)
|
|
||||||
{
|
|
||||||
func()
|
|
||||||
}else{
|
|
||||||
this.passiveConnectionEvent.push(func);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private eventMessage(data: string | ArrayBuffer)
|
|
||||||
{
|
|
||||||
if(typeof data == "string")
|
|
||||||
{
|
|
||||||
let $data = JSON.parse(data);
|
|
||||||
for (const callback of this.recaivePackEvent) {
|
|
||||||
callback($data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public tranferToServer(data:any)
|
|
||||||
{
|
|
||||||
if(this.connected)
|
|
||||||
{
|
|
||||||
this.ws.send(JSON.stringify(data));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
import MWSE from "./index";
|
|
||||||
import { Message } from "./WSTSProtocol";
|
|
||||||
|
|
||||||
export default class EventPool
|
|
||||||
{
|
|
||||||
public wsts : MWSE;
|
|
||||||
public events : Map<number, [Function,Function]> = new Map();
|
|
||||||
public signals : Map<string, Function[]> = new Map();
|
|
||||||
|
|
||||||
public requests : Map<number, [Function,Function]> = new Map();
|
|
||||||
|
|
||||||
public count = 0;
|
|
||||||
constructor(wsts:MWSE){
|
|
||||||
this.wsts = wsts;
|
|
||||||
}
|
|
||||||
public request(msg: Message) : Promise<any>
|
|
||||||
{
|
|
||||||
return new Promise((ok,rej) => {
|
|
||||||
let id = ++this.count;
|
|
||||||
this.events.set(id,[
|
|
||||||
(data:any) => {
|
|
||||||
ok(data);
|
|
||||||
},
|
|
||||||
(data:any) => {
|
|
||||||
rej(data);
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
this.wsts.WSTSProtocol.SendRequest(msg, id);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
public stream(msg: Message, callback: Function)
|
|
||||||
{
|
|
||||||
let id = ++this.count;
|
|
||||||
this.wsts.WSTSProtocol.StartStream(msg, id);
|
|
||||||
this.events.set(id,[
|
|
||||||
(data:any) => {
|
|
||||||
callback(data);
|
|
||||||
},
|
|
||||||
() => { }
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
public signal(event: string, callback: Function)
|
|
||||||
{
|
|
||||||
let T = this.signals.get(event);
|
|
||||||
if(!T)
|
|
||||||
{
|
|
||||||
this.signals.set(event, [callback]);
|
|
||||||
}else{
|
|
||||||
T.push(callback);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
export default class EventTarget
|
|
||||||
{
|
|
||||||
private events : {[key:string]:Function[]} = {};
|
|
||||||
public emit(eventName :string, ...args:any[])
|
|
||||||
{
|
|
||||||
if(this.events[eventName])
|
|
||||||
{
|
|
||||||
for (const callback of this.events[eventName]) {
|
|
||||||
callback(...args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public on(eventName :string, callback:Function)
|
|
||||||
{
|
|
||||||
if(this.events[eventName])
|
|
||||||
{
|
|
||||||
this.events[eventName].push(callback)
|
|
||||||
}else{
|
|
||||||
this.events[eventName] = [callback];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public activeScope : boolean = false;
|
|
||||||
scope(f:Function)
|
|
||||||
{
|
|
||||||
if(this.activeScope)
|
|
||||||
{
|
|
||||||
f()
|
|
||||||
}else{
|
|
||||||
this.on('scope', f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
import MWSE from "frontend";
|
|
||||||
|
|
||||||
export class IPPressure
|
|
||||||
{
|
|
||||||
public mwse : MWSE;
|
|
||||||
public APNumber? : number;
|
|
||||||
public APShortCode? : string;
|
|
||||||
public APIPAddress? : string;
|
|
||||||
constructor(mwse : MWSE){
|
|
||||||
this.mwse = mwse;
|
|
||||||
};
|
|
||||||
public async allocAPIPAddress()
|
|
||||||
{
|
|
||||||
let {status,ip} = await this.mwse.EventPooling.request({
|
|
||||||
type: 'alloc/APIPAddress'
|
|
||||||
}) as {
|
|
||||||
status:"fail"|"success",
|
|
||||||
ip?:string
|
|
||||||
};
|
|
||||||
if(status == 'success')
|
|
||||||
{
|
|
||||||
this.APIPAddress = ip;
|
|
||||||
return ip;
|
|
||||||
}else{
|
|
||||||
throw new Error("Error Allocated Access Point IP Address");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public async allocAPNumber()
|
|
||||||
{
|
|
||||||
let {status,number} = await this.mwse.EventPooling.request({
|
|
||||||
type: 'alloc/APNumber'
|
|
||||||
}) as {
|
|
||||||
status:"fail"|"success",
|
|
||||||
number?:number
|
|
||||||
};
|
|
||||||
if(status == 'success')
|
|
||||||
{
|
|
||||||
this.APNumber = number;
|
|
||||||
return number;
|
|
||||||
}else{
|
|
||||||
throw new Error("Error Allocated Access Point Number");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public async allocAPShortCode()
|
|
||||||
{
|
|
||||||
let {status,code} = await this.mwse.EventPooling.request({
|
|
||||||
type: 'alloc/APShortCode'
|
|
||||||
}) as {
|
|
||||||
status:"fail"|"success",
|
|
||||||
code?:string
|
|
||||||
};
|
|
||||||
if(status == 'success')
|
|
||||||
{
|
|
||||||
this.APShortCode = code;
|
|
||||||
return code;
|
|
||||||
}else{
|
|
||||||
throw new Error("Error Allocated Access Point Short Code");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public async reallocAPIPAddress()
|
|
||||||
{
|
|
||||||
let {status,ip} = await this.mwse.EventPooling.request({
|
|
||||||
type: 'realloc/APIPAddress'
|
|
||||||
}) as {
|
|
||||||
status:"fail"|"success",
|
|
||||||
ip?:string
|
|
||||||
};
|
|
||||||
if(status == 'success')
|
|
||||||
{
|
|
||||||
this.APIPAddress = ip;
|
|
||||||
return ip;
|
|
||||||
}else{
|
|
||||||
throw new Error("Error Reallocated Access Point IP Address");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public async reallocAPNumber()
|
|
||||||
{
|
|
||||||
let {status,number} = await this.mwse.EventPooling.request({
|
|
||||||
type: 'realloc/APNumber'
|
|
||||||
}) as {
|
|
||||||
status:"fail"|"success",
|
|
||||||
number?:number
|
|
||||||
};
|
|
||||||
if(status == 'success')
|
|
||||||
{
|
|
||||||
this.APNumber = number;
|
|
||||||
return number;
|
|
||||||
}else{
|
|
||||||
throw new Error("Error Reallocated Access Point Number");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public async reallocAPShortCode()
|
|
||||||
{
|
|
||||||
let {status,code} = await this.mwse.EventPooling.request({
|
|
||||||
type: 'realloc/APShortCode'
|
|
||||||
}) as {
|
|
||||||
status:"fail"|"success",
|
|
||||||
code?:string
|
|
||||||
};
|
|
||||||
if(status == 'success')
|
|
||||||
{
|
|
||||||
this.APShortCode = code;
|
|
||||||
return code;
|
|
||||||
}else{
|
|
||||||
throw new Error("Error Reallocated Access Point Short Code");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public async releaseAPIPAddress()
|
|
||||||
{
|
|
||||||
let {status} = await this.mwse.EventPooling.request({
|
|
||||||
type: 'release/APIPAddress'
|
|
||||||
}) as {
|
|
||||||
status:"fail"|"success",
|
|
||||||
};
|
|
||||||
if(status == 'success')
|
|
||||||
{
|
|
||||||
this.APIPAddress = undefined;
|
|
||||||
}else{
|
|
||||||
throw new Error("Error release Access Point IP Address");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public async releaseAPNumber()
|
|
||||||
{
|
|
||||||
let {status} = await this.mwse.EventPooling.request({
|
|
||||||
type: 'release/APNumber'
|
|
||||||
}) as {
|
|
||||||
status:"fail"|"success",
|
|
||||||
};
|
|
||||||
if(status == 'success')
|
|
||||||
{
|
|
||||||
this.APNumber = undefined;
|
|
||||||
}else{
|
|
||||||
throw new Error("Error release Access Point Number");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public async releaseAPShortCode()
|
|
||||||
{
|
|
||||||
let {status} = await this.mwse.EventPooling.request({
|
|
||||||
type: 'release/APShortCode'
|
|
||||||
}) as {
|
|
||||||
status:string
|
|
||||||
};
|
|
||||||
if(status == 'success')
|
|
||||||
{
|
|
||||||
this.APShortCode = undefined;
|
|
||||||
}else{
|
|
||||||
throw new Error("Error release Access Point Short Code");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public async queryAPIPAddress(ip:string)
|
|
||||||
{
|
|
||||||
let {status,socket} = await this.mwse.EventPooling.request({
|
|
||||||
type: 'whois/APIPAddress',
|
|
||||||
whois: ip
|
|
||||||
}) as {
|
|
||||||
status:"fail"|"success",
|
|
||||||
socket?:string
|
|
||||||
};
|
|
||||||
if(status == "success")
|
|
||||||
{
|
|
||||||
return socket;
|
|
||||||
}else{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public async queryAPNumber(number:number)
|
|
||||||
{
|
|
||||||
let {status,socket} = await this.mwse.EventPooling.request({
|
|
||||||
type: 'whois/APNumber',
|
|
||||||
whois: number
|
|
||||||
}) as {
|
|
||||||
status:"fail"|"success",
|
|
||||||
socket?:string
|
|
||||||
};
|
|
||||||
if(status == "success")
|
|
||||||
{
|
|
||||||
return socket;
|
|
||||||
}else{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public async queryAPShortCode(code:string)
|
|
||||||
{
|
|
||||||
let {status,socket} = await this.mwse.EventPooling.request({
|
|
||||||
type: 'whois/APShortCode',
|
|
||||||
whois: code
|
|
||||||
}) as {
|
|
||||||
status:"fail"|"success",
|
|
||||||
socket?:string
|
|
||||||
};
|
|
||||||
if(status == "success")
|
|
||||||
{
|
|
||||||
return socket;
|
|
||||||
}else{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,262 +0,0 @@
|
||||||
import WebRTC from "./WebRTC";
|
|
||||||
import Peer from "./Peer";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deneyseldir kullanılması önerilmez
|
|
||||||
*/
|
|
||||||
export default class P2PFileSender
|
|
||||||
{
|
|
||||||
public rtc : RTCPeerConnection;
|
|
||||||
public peer : Peer;
|
|
||||||
public webrtc : WebRTC;
|
|
||||||
|
|
||||||
public totalSize : number = 0;
|
|
||||||
public isReady : boolean = false;
|
|
||||||
public isStarted : boolean = false;
|
|
||||||
public isSending : boolean = false;
|
|
||||||
public isRecaiving : boolean = false;
|
|
||||||
public processedSize : number = 0;
|
|
||||||
public recaivedFile? : File;
|
|
||||||
|
|
||||||
public bufferSizePerChannel : number = 10e6;
|
|
||||||
public bufferSizePerPack : number = 10e3;
|
|
||||||
public safeBufferSizePerPack : number = 10e3 - 1;
|
|
||||||
|
|
||||||
public constructor(webrtc : WebRTC, peer : Peer)
|
|
||||||
{
|
|
||||||
this.webrtc = webrtc;
|
|
||||||
this.rtc = webrtc.rtc;
|
|
||||||
this.peer = peer;
|
|
||||||
}
|
|
||||||
public async RecaiveFile(
|
|
||||||
_rtc: RTCPeerConnection,
|
|
||||||
fileMetadata: {name:string, type:string},
|
|
||||||
channelCount: number,
|
|
||||||
_totalSize: number,
|
|
||||||
onEnded: Function
|
|
||||||
)
|
|
||||||
{
|
|
||||||
//let totals = {};
|
|
||||||
// let index = 0;
|
|
||||||
/*setChannelStatus(Array.from({length:channelCount}).map((e, index) => {
|
|
||||||
return {
|
|
||||||
name: `${index+1}. Kanal`,
|
|
||||||
current: 0,
|
|
||||||
currentTotal: 0,
|
|
||||||
total: 0
|
|
||||||
}
|
|
||||||
}));*/
|
|
||||||
let parts : Blob[] = [];
|
|
||||||
this.webrtc.on('datachannel',(datachannel:RTCDataChannel) => {
|
|
||||||
//let channelIndex = index++;
|
|
||||||
let current = 0;
|
|
||||||
let totalSize = 0;
|
|
||||||
let currentPart = 0;
|
|
||||||
let bufferAmount : ArrayBuffer[] = [];
|
|
||||||
datachannel.onmessage = function({data}){
|
|
||||||
if(totalSize == 0)
|
|
||||||
{
|
|
||||||
let {
|
|
||||||
size,
|
|
||||||
part,
|
|
||||||
} = JSON.parse(data);
|
|
||||||
totalSize = size;
|
|
||||||
currentPart = part;
|
|
||||||
/*updateChannelStatus(channelIndex, n => {
|
|
||||||
return {
|
|
||||||
...n,
|
|
||||||
total: totalSize,
|
|
||||||
current: 0
|
|
||||||
}
|
|
||||||
});*/
|
|
||||||
datachannel.send("READY");
|
|
||||||
}else{
|
|
||||||
current += data.byteLength;
|
|
||||||
bufferAmount.push(data);
|
|
||||||
/*updateChannelStatus(channelIndex, n => {
|
|
||||||
return {
|
|
||||||
...n,
|
|
||||||
current: data.byteLength + n.current,
|
|
||||||
currentTotal: data.byteLength + n.currentTotal,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setProcessedSize(n => n + data.byteLength);*/
|
|
||||||
if(current == totalSize)
|
|
||||||
{
|
|
||||||
parts[currentPart] = new Blob(bufferAmount);
|
|
||||||
bufferAmount = [];
|
|
||||||
//totals[datachannel.label] += totalSize;
|
|
||||||
totalSize = 0;
|
|
||||||
currentPart = 0;
|
|
||||||
current = 0;
|
|
||||||
datachannel.send("TOTAL_RECAIVED");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
datachannel.onclose = () => {
|
|
||||||
channelCount--;
|
|
||||||
if(channelCount == 0)
|
|
||||||
{
|
|
||||||
let file = new File(parts, fileMetadata.name, {
|
|
||||||
type: fileMetadata.type,
|
|
||||||
lastModified: +new Date
|
|
||||||
});
|
|
||||||
onEnded(file);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
}
|
|
||||||
public async SendFile(
|
|
||||||
file: File,
|
|
||||||
metadata: object
|
|
||||||
)
|
|
||||||
{
|
|
||||||
this.isSending = true;
|
|
||||||
this.isStarted = true;
|
|
||||||
|
|
||||||
|
|
||||||
let buffer = await file.arrayBuffer();
|
|
||||||
let partCount = Math.ceil(buffer.byteLength / 10e6);
|
|
||||||
let channelCount = Math.min(5, partCount);
|
|
||||||
|
|
||||||
if(this.webrtc.iceStatus != "connected")
|
|
||||||
{
|
|
||||||
throw new Error("WebRTC is a not ready")
|
|
||||||
}
|
|
||||||
|
|
||||||
this.peer.send({
|
|
||||||
type: 'file',
|
|
||||||
name: file.name,
|
|
||||||
size: file.size,
|
|
||||||
mimetype: file.type,
|
|
||||||
partCount,
|
|
||||||
channelCount,
|
|
||||||
metadata: metadata
|
|
||||||
});
|
|
||||||
|
|
||||||
let channels : RTCDataChannel[] = [];
|
|
||||||
|
|
||||||
for(let channelIndex = 0; channelIndex < channelCount; channelIndex++)
|
|
||||||
{
|
|
||||||
let channel = this.rtc.createDataChannel("\\?\\file_" + channelIndex);
|
|
||||||
channel.binaryType = "arraybuffer";
|
|
||||||
await new Promise(ok => {
|
|
||||||
channel.onopen = () => {
|
|
||||||
ok(void 0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
channels.push(channel);
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentPart = 0;
|
|
||||||
let next = () => {
|
|
||||||
if(currentPart < partCount)
|
|
||||||
{
|
|
||||||
let bufferPart = buffer.slice(currentPart * 10e6, currentPart * 10e6 + 10e6)
|
|
||||||
currentPart++;
|
|
||||||
return [bufferPart, currentPart - 1];
|
|
||||||
};
|
|
||||||
return [false,0];
|
|
||||||
};
|
|
||||||
let spyChannelIndex = channels.length;
|
|
||||||
await new Promise(ok => {
|
|
||||||
for (let channelIndex = 0; channelIndex < channels.length; channelIndex++)
|
|
||||||
{
|
|
||||||
this.sendPartition(
|
|
||||||
channels[channelIndex],
|
|
||||||
next,
|
|
||||||
channelIndex,
|
|
||||||
() => {
|
|
||||||
spyChannelIndex--;
|
|
||||||
if(spyChannelIndex == 0)
|
|
||||||
{
|
|
||||||
this.isSending = false;
|
|
||||||
this.isStarted = false;
|
|
||||||
ok(undefined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
protected sendPartition(
|
|
||||||
channel: RTCDataChannel,
|
|
||||||
nextblob10mb: () => (number | ArrayBuffer)[] | (number | boolean)[],
|
|
||||||
_channelIndex: number,
|
|
||||||
onEnded: Function
|
|
||||||
)
|
|
||||||
{
|
|
||||||
let [currentBuffer,currentPartition] = nextblob10mb();
|
|
||||||
let currentPart = 0;
|
|
||||||
let next = () => {
|
|
||||||
if(!(currentBuffer instanceof ArrayBuffer))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let bufferPart = currentBuffer.slice(currentPart * 16e3, currentPart * 16e3 + 16e3)
|
|
||||||
currentPart++;
|
|
||||||
if(bufferPart.byteLength != 0)
|
|
||||||
{
|
|
||||||
/*
|
|
||||||
updateChannelStatus(channelIndex, n => {
|
|
||||||
return {
|
|
||||||
...n,
|
|
||||||
current: bufferPart.byteLength + n.current,
|
|
||||||
currentTotal: bufferPart.byteLength + n.currentTotal
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setProcessedSize(n => n + bufferPart.byteLength);
|
|
||||||
*/
|
|
||||||
return bufferPart
|
|
||||||
}
|
|
||||||
};
|
|
||||||
channel.addEventListener("message",({data}) => {
|
|
||||||
if(data == "READY")
|
|
||||||
{
|
|
||||||
this.sendFileChannel(channel, next)
|
|
||||||
}
|
|
||||||
if(data == "TOTAL_RECAIVED")
|
|
||||||
{
|
|
||||||
[currentBuffer,currentPartition] = nextblob10mb();
|
|
||||||
currentPart = 0;
|
|
||||||
if(currentBuffer != false)
|
|
||||||
{
|
|
||||||
/*updateChannelStatus(channelIndex, n => {
|
|
||||||
return {
|
|
||||||
...n,
|
|
||||||
total: currentBuffer.byteLength,
|
|
||||||
current: 0,
|
|
||||||
}
|
|
||||||
});*/
|
|
||||||
channel.send(JSON.stringify({
|
|
||||||
size: (currentBuffer as ArrayBuffer).byteLength,
|
|
||||||
part: currentPartition
|
|
||||||
}))
|
|
||||||
}else{
|
|
||||||
channel.close();
|
|
||||||
onEnded();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
channel.send(JSON.stringify({
|
|
||||||
size: (currentBuffer as ArrayBuffer).byteLength,
|
|
||||||
part: currentPartition
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
protected sendFileChannel(
|
|
||||||
channel: RTCDataChannel,
|
|
||||||
getNextBlob: () => ArrayBuffer | undefined
|
|
||||||
)
|
|
||||||
{
|
|
||||||
channel.addEventListener("bufferedamountlow",function(){
|
|
||||||
let buffer = getNextBlob();
|
|
||||||
if(buffer)
|
|
||||||
{
|
|
||||||
channel.send(buffer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
channel.bufferedAmountLowThreshold = 16e3 - 1;
|
|
||||||
let c = getNextBlob();
|
|
||||||
c && channel.send(c);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
234
frontend/Peer.ts
234
frontend/Peer.ts
|
|
@ -1,234 +0,0 @@
|
||||||
import EventTarget from "./EventTarget";
|
|
||||||
import { PeerInfo } from "./PeerInfo";
|
|
||||||
import WebRTC from "./WebRTC";
|
|
||||||
import MWSE from "./index";
|
|
||||||
|
|
||||||
interface IPeerOptions{
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
enum IMessageSymbase
|
|
||||||
{
|
|
||||||
PayloadMessagePack = -12873.54,
|
|
||||||
PayloadRTCBasePack = -12884.54
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export default class Peer extends EventTarget
|
|
||||||
{
|
|
||||||
public mwse : MWSE;
|
|
||||||
public options : IPeerOptions = {};
|
|
||||||
public socketId? : string;
|
|
||||||
public selfSocket : boolean = false;
|
|
||||||
public active : boolean = false;
|
|
||||||
public info : PeerInfo;
|
|
||||||
public rtc : WebRTC;
|
|
||||||
public peerConnection : boolean = false;
|
|
||||||
public primaryChannel : "websocket" | "datachannel" = "datachannel";
|
|
||||||
constructor(wsts:MWSE){
|
|
||||||
super();
|
|
||||||
this.mwse = wsts;
|
|
||||||
this.rtc = this.createRTC();
|
|
||||||
this.info = new PeerInfo(this);
|
|
||||||
this.on('pack',(data:{type?:string,action?:IMessageSymbase,payload?:any}) => {
|
|
||||||
if(data.type == ':rtcpack:')
|
|
||||||
{
|
|
||||||
return this.rtc.emit("input", data.payload)
|
|
||||||
};
|
|
||||||
this.emit("message", data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
public createRTC(rtcConfig?: RTCConfiguration | undefined, rtcServers?: RTCIceServer[] | undefined) : WebRTC
|
|
||||||
{
|
|
||||||
this.rtc = new WebRTC(rtcConfig,rtcServers);
|
|
||||||
this.rtc.peer = this;
|
|
||||||
this.rtc.on("connected", () => {
|
|
||||||
this.peerConnection = true;
|
|
||||||
});
|
|
||||||
this.rtc.on('disconnected', () => {
|
|
||||||
this.peerConnection = false;
|
|
||||||
})
|
|
||||||
this.rtc.on("output",(payload:object) => {
|
|
||||||
this.send({
|
|
||||||
type: ':rtcpack:',
|
|
||||||
payload: payload
|
|
||||||
})
|
|
||||||
});
|
|
||||||
this.rtc.on("message",(payload:object) => {
|
|
||||||
this.emit("pack",payload);
|
|
||||||
});
|
|
||||||
return this.rtc;
|
|
||||||
}
|
|
||||||
public setPeerOptions(options: string | IPeerOptions){
|
|
||||||
if(typeof options == "string")
|
|
||||||
{
|
|
||||||
this.setSocketId(options)
|
|
||||||
}else{
|
|
||||||
this.options = options;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public setSocketId(uuid: string){
|
|
||||||
this.socketId = uuid;
|
|
||||||
}
|
|
||||||
async metadata() : Promise<any>
|
|
||||||
{
|
|
||||||
if(this.socketId == 'me')
|
|
||||||
{
|
|
||||||
let result = await this.mwse.EventPooling.request({
|
|
||||||
type:'my/socketid'
|
|
||||||
});
|
|
||||||
this.selfSocket = true;
|
|
||||||
this.active ||= true;
|
|
||||||
this.socketId = result;
|
|
||||||
this.emit('scope');
|
|
||||||
this.activeScope = true;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
async request(pack:any){
|
|
||||||
if(this.active)
|
|
||||||
{
|
|
||||||
return await this.mwse.request(this.socketId as string, pack);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
equalTo(peer : Peer | {socketId: string})
|
|
||||||
{
|
|
||||||
return this.socketId == peer.socketId;
|
|
||||||
}
|
|
||||||
async isReachable()
|
|
||||||
{
|
|
||||||
return await this.mwse.EventPooling.request({
|
|
||||||
type:'is/reachable',
|
|
||||||
to: this.socketId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async enablePairAuth(){
|
|
||||||
await this.mwse.EventPooling.request({
|
|
||||||
type:'auth/pair-system',
|
|
||||||
value: 'everybody'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async disablePairAuth(){
|
|
||||||
await this.mwse.EventPooling.request({
|
|
||||||
type:'auth/pair-system',
|
|
||||||
value: 'disable'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async enablePairInfo(){
|
|
||||||
await this.mwse.EventPooling.request({
|
|
||||||
type: 'connection/pairinfo',
|
|
||||||
value: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async disablePairInfo(){
|
|
||||||
await this.mwse.EventPooling.request({
|
|
||||||
type: 'connection/pairinfo',
|
|
||||||
value: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async requestPair()
|
|
||||||
{
|
|
||||||
let {message,status} = await this.mwse.EventPooling.request({
|
|
||||||
type:'request/pair',
|
|
||||||
to: this.socketId
|
|
||||||
});
|
|
||||||
if(
|
|
||||||
message == "ALREADY-PAIRED" ||
|
|
||||||
message == "ALREADY-REQUESTED"
|
|
||||||
)
|
|
||||||
{
|
|
||||||
console.warn("Already paired or pair requested")
|
|
||||||
};
|
|
||||||
if(status == "fail")
|
|
||||||
{
|
|
||||||
console.error("Request Pair Error",status, message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
async endPair()
|
|
||||||
{
|
|
||||||
await this.mwse.EventPooling.request({
|
|
||||||
type:'end/pair',
|
|
||||||
to: this.socketId
|
|
||||||
});
|
|
||||||
this.forget();
|
|
||||||
}
|
|
||||||
async acceptPair()
|
|
||||||
{
|
|
||||||
let {message,status} = await this.mwse.EventPooling.request({
|
|
||||||
type:'accept/pair',
|
|
||||||
to: this.socketId
|
|
||||||
});
|
|
||||||
if(status == "fail")
|
|
||||||
{
|
|
||||||
console.error("Pair Error",status, message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
async rejectPair()
|
|
||||||
{
|
|
||||||
let {message,status} = await this.mwse.EventPooling.request({
|
|
||||||
type:'reject/pair',
|
|
||||||
to: this.socketId
|
|
||||||
});
|
|
||||||
if(status == "fail")
|
|
||||||
{
|
|
||||||
console.error("Pair Error",status, message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
async getPairedList() : Promise<string>
|
|
||||||
{
|
|
||||||
let {value} = await this.mwse.EventPooling.request({
|
|
||||||
type:'pair/list',
|
|
||||||
to: this.socketId
|
|
||||||
});
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
async send(pack: any){
|
|
||||||
let isOpenedP2P = this.peerConnection && this.rtc?.active;
|
|
||||||
let isOpenedServer = this.mwse.server.connected;
|
|
||||||
let sendChannel : "websocket" | "datachannel";
|
|
||||||
if(isOpenedP2P && isOpenedServer)
|
|
||||||
{
|
|
||||||
if(this.primaryChannel == "websocket")
|
|
||||||
{
|
|
||||||
sendChannel = "websocket"
|
|
||||||
}else
|
|
||||||
{
|
|
||||||
sendChannel = "datachannel"
|
|
||||||
}
|
|
||||||
}else if(isOpenedServer){
|
|
||||||
sendChannel = "websocket"
|
|
||||||
}else{
|
|
||||||
sendChannel = "datachannel"
|
|
||||||
}
|
|
||||||
|
|
||||||
if(sendChannel == "websocket")
|
|
||||||
{
|
|
||||||
if(!this.mwse.writable){
|
|
||||||
return console.warn("Socket is not writable");
|
|
||||||
}
|
|
||||||
await this.mwse.EventPooling.request({
|
|
||||||
type:'pack/to',
|
|
||||||
pack,
|
|
||||||
to: this.socketId
|
|
||||||
});
|
|
||||||
}else{
|
|
||||||
if(pack.type != ':rtcpack:')
|
|
||||||
{
|
|
||||||
this.rtc?.sendMessage(pack)
|
|
||||||
}else{
|
|
||||||
return console.warn("Socket is not writable");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async forget(){
|
|
||||||
this.mwse.peers.delete(this.socketId as string);
|
|
||||||
this.mwse.pairs.delete(this.socketId as string);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
import Peer from "./Peer";
|
|
||||||
|
|
||||||
export class PeerInfo
|
|
||||||
{
|
|
||||||
public peer : Peer;
|
|
||||||
public info : {[key:string]: any} = {};
|
|
||||||
constructor(mwse : Peer){
|
|
||||||
this.peer = mwse;
|
|
||||||
};
|
|
||||||
public async fetch(name?:string)
|
|
||||||
{
|
|
||||||
if(name)
|
|
||||||
{
|
|
||||||
let rinfo = await this.peer.mwse.EventPooling.request(({
|
|
||||||
type: "peer/info",
|
|
||||||
peer: this.peer.socketId,
|
|
||||||
name
|
|
||||||
}));
|
|
||||||
if(rinfo.status == "success")
|
|
||||||
{
|
|
||||||
this.info = rinfo.info;
|
|
||||||
}else console.warn(rinfo.message);
|
|
||||||
}else{
|
|
||||||
let rinfo = await this.peer.mwse.EventPooling.request(({
|
|
||||||
type: "peer/info",
|
|
||||||
peer: this.peer.socketId
|
|
||||||
}));
|
|
||||||
if(rinfo.status == "success")
|
|
||||||
{
|
|
||||||
this.info = rinfo.info;
|
|
||||||
}else console.warn(rinfo.message);
|
|
||||||
};
|
|
||||||
return this.info;
|
|
||||||
}
|
|
||||||
public set(name: string, value: string | number)
|
|
||||||
{
|
|
||||||
this.info[name] = value;
|
|
||||||
this.peer.mwse.WSTSProtocol.SendOnly({
|
|
||||||
type: "auth/info",
|
|
||||||
name,
|
|
||||||
value
|
|
||||||
});
|
|
||||||
}
|
|
||||||
public get(name?:string)
|
|
||||||
{
|
|
||||||
return name ? this.info[name] : this.info;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
179
frontend/Room.ts
179
frontend/Room.ts
|
|
@ -1,179 +0,0 @@
|
||||||
import EventTarget from "./EventTarget";
|
|
||||||
import MWSE from "./index";
|
|
||||||
import Peer from "./Peer";
|
|
||||||
import { RoomInfo } from "./RoomInfo";
|
|
||||||
|
|
||||||
export interface IRoomOptions
|
|
||||||
{
|
|
||||||
name: string;
|
|
||||||
description?:string;
|
|
||||||
joinType: "free"|"invite"|"password"|"lock";
|
|
||||||
credential?: string;
|
|
||||||
ifexistsJoin?: boolean;
|
|
||||||
accessType?: "public"|"private";
|
|
||||||
notifyActionInvite?: boolean;
|
|
||||||
notifyActionJoined?: boolean;
|
|
||||||
notifyActionEjected?: boolean;
|
|
||||||
autoFetchInfo?:boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export default class Room extends EventTarget
|
|
||||||
{
|
|
||||||
public mwse : MWSE;
|
|
||||||
public options! : IRoomOptions;
|
|
||||||
public config! : IRoomOptions;
|
|
||||||
public roomId? : string;
|
|
||||||
public accessType? : "public"|"private";
|
|
||||||
public description? : string;
|
|
||||||
public joinType? : "free"|"invite"|"password"|"lock";
|
|
||||||
public name? : string;
|
|
||||||
public owner? : string;
|
|
||||||
public peers : Map<string,Peer> = new Map();
|
|
||||||
public info : RoomInfo;
|
|
||||||
|
|
||||||
constructor(wsts:MWSE){
|
|
||||||
super();
|
|
||||||
this.mwse = wsts;
|
|
||||||
this.info = new RoomInfo(this);
|
|
||||||
}
|
|
||||||
public setRoomOptions(options : IRoomOptions | string)
|
|
||||||
{
|
|
||||||
if(typeof options == "string")
|
|
||||||
{
|
|
||||||
this.roomId = options;
|
|
||||||
}else{
|
|
||||||
let defaultOptions = {
|
|
||||||
joinType: "free",
|
|
||||||
ifexistsJoin: true,
|
|
||||||
accessType: "private",
|
|
||||||
notifyActionInvite: true,
|
|
||||||
notifyActionJoined: true,
|
|
||||||
notifyActionEjected: true,
|
|
||||||
autoFetchInfo: true
|
|
||||||
};
|
|
||||||
Object.assign(defaultOptions,options);
|
|
||||||
this.config = defaultOptions as IRoomOptions;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setRoomId(uuid: string){
|
|
||||||
this.roomId = uuid;
|
|
||||||
}
|
|
||||||
async createRoom(roomOptions : IRoomOptions){
|
|
||||||
let config = this.config || roomOptions;
|
|
||||||
let result = await this.mwse.EventPooling.request({
|
|
||||||
type:'create-room',
|
|
||||||
...config
|
|
||||||
});
|
|
||||||
if(result.status == 'fail')
|
|
||||||
{
|
|
||||||
if(result.message == "ALREADY-EXISTS" && this.config.ifexistsJoin)
|
|
||||||
{
|
|
||||||
return this.join();
|
|
||||||
}
|
|
||||||
throw new Error(result.message || result.messages);
|
|
||||||
}else{
|
|
||||||
this.options = {
|
|
||||||
...this.config,
|
|
||||||
...result.room
|
|
||||||
};
|
|
||||||
this.roomId = result.room.id;
|
|
||||||
this.mwse.rooms.set(this.roomId as string, this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async join(){
|
|
||||||
let result = await this.mwse.EventPooling.request({
|
|
||||||
type:'joinroom',
|
|
||||||
name: this.config.name,
|
|
||||||
credential: this.config.credential,
|
|
||||||
autoFetchInfo: this.config.autoFetchInfo || false
|
|
||||||
});
|
|
||||||
if(result.status == 'fail')
|
|
||||||
{
|
|
||||||
throw new Error(result.message);
|
|
||||||
}else{
|
|
||||||
this.options = {
|
|
||||||
...this.config,
|
|
||||||
...result.room
|
|
||||||
};
|
|
||||||
if(result.info)
|
|
||||||
{
|
|
||||||
this.info.info = result.info;
|
|
||||||
};
|
|
||||||
this.roomId = result.room.id;
|
|
||||||
this.mwse.rooms.set(this.roomId as string, this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async eject(){
|
|
||||||
let {type} = await this.mwse.EventPooling.request({
|
|
||||||
type:'ejectroom',
|
|
||||||
roomId: this.roomId
|
|
||||||
});
|
|
||||||
this.peers.clear();
|
|
||||||
if(type == 'success')
|
|
||||||
{
|
|
||||||
this.mwse.rooms.delete(this.roomId as string);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async send(pack: any, wom:boolean = false, handshake = false){
|
|
||||||
if(!this.mwse.writable){
|
|
||||||
return console.warn("Socket is not writable");
|
|
||||||
}
|
|
||||||
if(handshake)
|
|
||||||
{
|
|
||||||
let {type} = await this.mwse.EventPooling.request({
|
|
||||||
type:'pack/room',
|
|
||||||
pack,
|
|
||||||
to: this.roomId,
|
|
||||||
wom,
|
|
||||||
handshake
|
|
||||||
}) as {
|
|
||||||
type:"success"|"fail"
|
|
||||||
};
|
|
||||||
if(type == "fail"){
|
|
||||||
throw new Error("Cant send message to room")
|
|
||||||
}
|
|
||||||
}else{
|
|
||||||
await this.mwse.EventPooling.request({
|
|
||||||
type:'pack/room',
|
|
||||||
pack,
|
|
||||||
to: this.roomId,
|
|
||||||
wom,
|
|
||||||
handshake
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async fetchPeers(filter?:{[key:string]:any}, onlyNumber:boolean = false) : Promise<Number | Peer[]>
|
|
||||||
{
|
|
||||||
if(onlyNumber)
|
|
||||||
{
|
|
||||||
let {count} = await this.mwse.EventPooling.request({
|
|
||||||
type:'room/peer-count',
|
|
||||||
roomId: this.roomId,
|
|
||||||
filter: filter || {}
|
|
||||||
}) as {count:Number};
|
|
||||||
return count;
|
|
||||||
}else{
|
|
||||||
let {status, peers} = await this.mwse.EventPooling.request({
|
|
||||||
type:'room-peers',
|
|
||||||
roomId: this.roomId,
|
|
||||||
filter: filter || {}
|
|
||||||
}) as {status:"success"|"fail", peers: string[]};
|
|
||||||
|
|
||||||
let cup : Peer[] = [];
|
|
||||||
|
|
||||||
if(status == 'fail')
|
|
||||||
{
|
|
||||||
throw new Error("Cant using peers on room")
|
|
||||||
}else if(status == 'success'){
|
|
||||||
for (const peerid of peers) {
|
|
||||||
let peer = this.mwse.peer(peerid,true);
|
|
||||||
cup.push(peer);
|
|
||||||
this.peers.set(peerid, peer);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return cup;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
import Room from "./Room";
|
|
||||||
|
|
||||||
export class RoomInfo
|
|
||||||
{
|
|
||||||
public room : Room;
|
|
||||||
public info : {[key:string]: any} = {};
|
|
||||||
constructor(room : Room){
|
|
||||||
this.room = room;
|
|
||||||
this.room.on('updateinfo',(name:string,value:any) => {
|
|
||||||
this.info[name] = value;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
public async fetch(name?:string)
|
|
||||||
{
|
|
||||||
if(name)
|
|
||||||
{
|
|
||||||
let rinfo = await this.room.mwse.EventPooling.request(({
|
|
||||||
type: "room/getinfo",
|
|
||||||
roomId: this.room.roomId,
|
|
||||||
name
|
|
||||||
}));
|
|
||||||
if(rinfo.status == "success")
|
|
||||||
{
|
|
||||||
this.info = rinfo.value;
|
|
||||||
}else console.warn(rinfo.message);
|
|
||||||
}else{
|
|
||||||
let rinfo = await this.room.mwse.EventPooling.request(({
|
|
||||||
type: "room/info",
|
|
||||||
roomId: this.room.roomId
|
|
||||||
}));
|
|
||||||
if(rinfo.status == "success")
|
|
||||||
{
|
|
||||||
this.info = rinfo.value;
|
|
||||||
}else console.warn(rinfo.message);
|
|
||||||
};
|
|
||||||
return this.info;
|
|
||||||
}
|
|
||||||
public set(name: string, value: string | number)
|
|
||||||
{
|
|
||||||
this.info[name] = value;
|
|
||||||
this.room.mwse.WSTSProtocol.SendOnly({
|
|
||||||
type: "room/setinfo",
|
|
||||||
roomId: this.room.roomId,
|
|
||||||
name,
|
|
||||||
value
|
|
||||||
});
|
|
||||||
}
|
|
||||||
public get(name?:string)
|
|
||||||
{
|
|
||||||
return name ? this.info[name] : this.info;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
import MWSE from "./index";
|
|
||||||
|
|
||||||
export interface Message {
|
|
||||||
[key:string|number]:any;
|
|
||||||
}
|
|
||||||
export default class WSTSProtocol
|
|
||||||
{
|
|
||||||
public mwse : MWSE;
|
|
||||||
constructor(wsts:MWSE){
|
|
||||||
this.mwse = wsts;
|
|
||||||
this.addListener();
|
|
||||||
}
|
|
||||||
public addListener()
|
|
||||||
{
|
|
||||||
this.mwse.server?.onRecaivePack((pack)=>{
|
|
||||||
this.PackAnalyze(pack)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
public SendRaw(pack: Message)
|
|
||||||
{
|
|
||||||
this.mwse.server.tranferToServer(pack);
|
|
||||||
}
|
|
||||||
public SendOnly(pack: Message)
|
|
||||||
{
|
|
||||||
this.mwse.server.tranferToServer([pack,'R']);
|
|
||||||
}
|
|
||||||
public SendRequest(pack: Message, id: number)
|
|
||||||
{
|
|
||||||
this.mwse.server.tranferToServer([pack, id, 'R']);
|
|
||||||
}
|
|
||||||
public StartStream(pack: Message, id: number)
|
|
||||||
{
|
|
||||||
this.mwse.server.tranferToServer([pack, id, 'S']);
|
|
||||||
}
|
|
||||||
public PackAnalyze(data:any)
|
|
||||||
{
|
|
||||||
let [payload, id, action] = data;
|
|
||||||
if(typeof id === 'number')
|
|
||||||
{
|
|
||||||
let callback = this.mwse.EventPooling.events.get(id);
|
|
||||||
if(callback)
|
|
||||||
{
|
|
||||||
callback[0](payload, action);
|
|
||||||
switch(action)
|
|
||||||
{
|
|
||||||
case 'E':{ // [E]ND flag
|
|
||||||
this.mwse.EventPooling.events.delete(id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'S': // [S]TREAM flag
|
|
||||||
default:{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}else console.warn("Missing event sended from server");
|
|
||||||
}else{
|
|
||||||
let signals = this.mwse.EventPooling.signals.get(id);
|
|
||||||
if(signals)
|
|
||||||
{
|
|
||||||
for (const callback of signals) {
|
|
||||||
callback(payload);
|
|
||||||
}
|
|
||||||
}else console.warn("Missing event sended from server");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,522 +0,0 @@
|
||||||
import P2PFileSender from "./P2PFileSender";
|
|
||||||
import Peer from "./Peer";
|
|
||||||
interface TransferStreamInfo
|
|
||||||
{
|
|
||||||
senders : RTCRtpSender[];
|
|
||||||
recaivers : RTCRtpReceiver[];
|
|
||||||
stream:MediaStream | undefined;
|
|
||||||
id:string;
|
|
||||||
name:string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class WebRTC
|
|
||||||
{
|
|
||||||
public static channels : Map<any,any> = new Map();
|
|
||||||
public static requireGC : boolean = false;
|
|
||||||
public id : any;
|
|
||||||
public active : boolean = false;
|
|
||||||
public connectionStatus : "closed" | "connected" | "connecting" | "disconnected" | "failed" | "new" = "new";
|
|
||||||
public iceStatus : "checking" | "closed" | "completed" | "connected" | "disconnected" | "failed" | "new" = "new";
|
|
||||||
public gatheringStatus : "complete" | "gathering" | "new" = "new";
|
|
||||||
public signalingStatus : "" | "closed" | "have-local-offer" | "have-local-pranswer" | "have-remote-offer" | "have-remote-pranswer" | "stable" = ""
|
|
||||||
public rtc! : RTCPeerConnection;
|
|
||||||
public recaivingStream : Map<string, TransferStreamInfo> = new Map();
|
|
||||||
public sendingStream : Map<string, TransferStreamInfo> = new Map();
|
|
||||||
public events : { [eventname:string]: Function[] } = {};
|
|
||||||
public channel : RTCDataChannel | undefined;
|
|
||||||
|
|
||||||
public static defaultRTCConfig : RTCConfiguration = {
|
|
||||||
iceCandidatePoolSize: 0,
|
|
||||||
iceTransportPolicy:"all",
|
|
||||||
rtcpMuxPolicy:"require",
|
|
||||||
};
|
|
||||||
|
|
||||||
private isPolite() : boolean
|
|
||||||
{
|
|
||||||
let myId = this.peer?.mwse.peer('me').socketId as string;
|
|
||||||
let peerId = this.peer?.socketId as string;
|
|
||||||
return myId < peerId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static defaultICEServers : RTCIceServer[] = [{
|
|
||||||
urls: "stun:stun.l.google.com:19302"
|
|
||||||
},{
|
|
||||||
urls: "stun:stun1.l.google.com:19302"
|
|
||||||
},{
|
|
||||||
urls: "stun:stun2.l.google.com:19302"
|
|
||||||
},{
|
|
||||||
urls: "stun:stun3.l.google.com:19302"
|
|
||||||
},{
|
|
||||||
urls: "stun:stun4.l.google.com:19302"
|
|
||||||
}];
|
|
||||||
|
|
||||||
public peer? : Peer;
|
|
||||||
|
|
||||||
public FileTransportChannel? : P2PFileSender;
|
|
||||||
|
|
||||||
public makingOffer = false;
|
|
||||||
public ignoreOffer = false;
|
|
||||||
public isSettingRemoteAnswerPending = false;
|
|
||||||
|
|
||||||
candicatePack : RTCIceCandidate[] = [];
|
|
||||||
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
rtcConfig?: RTCConfiguration,
|
|
||||||
rtcServers?: RTCIceServer[]
|
|
||||||
)
|
|
||||||
{
|
|
||||||
let config : any = {};
|
|
||||||
|
|
||||||
if(rtcConfig)
|
|
||||||
{
|
|
||||||
Object.assign(
|
|
||||||
config,
|
|
||||||
WebRTC.defaultRTCConfig,
|
|
||||||
rtcConfig
|
|
||||||
)
|
|
||||||
}else{
|
|
||||||
Object.assign(
|
|
||||||
config,
|
|
||||||
WebRTC.defaultRTCConfig
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
config.iceServers = rtcServers || WebRTC.defaultICEServers;
|
|
||||||
|
|
||||||
this.rtc = new RTCPeerConnection(config as RTCConfiguration);
|
|
||||||
this.rtc.addEventListener("connectionstatechange",()=>{
|
|
||||||
this.eventConnectionState();
|
|
||||||
})
|
|
||||||
this.rtc.addEventListener("icecandidate",(...args)=>{
|
|
||||||
this.eventIcecandidate(...args);
|
|
||||||
})
|
|
||||||
this.rtc.addEventListener("iceconnectionstatechange",()=>{
|
|
||||||
this.eventICEConnectionState();
|
|
||||||
})
|
|
||||||
this.rtc.addEventListener("icegatheringstatechange",()=>{
|
|
||||||
this.eventICEGatherinState();
|
|
||||||
})
|
|
||||||
this.rtc.addEventListener("negotiationneeded",()=>{
|
|
||||||
this.eventNogationNeeded();
|
|
||||||
})
|
|
||||||
this.rtc.addEventListener("signalingstatechange",()=>{
|
|
||||||
this.eventSignalingState();
|
|
||||||
})
|
|
||||||
this.rtc.addEventListener("track",(...args)=>{
|
|
||||||
this.eventTrack(...args);
|
|
||||||
})
|
|
||||||
this.rtc.addEventListener("datachannel",(...args)=>{
|
|
||||||
this.eventDatachannel(...args);
|
|
||||||
})
|
|
||||||
this.on('input',async (data:{[key:string]:any})=>{
|
|
||||||
switch(data.type)
|
|
||||||
{
|
|
||||||
case "icecandidate":{
|
|
||||||
try{
|
|
||||||
if(this.rtc.remoteDescription){
|
|
||||||
await this.rtc.addIceCandidate(new RTCIceCandidate(data.value));
|
|
||||||
}else{
|
|
||||||
this.candicatePack.push(new RTCIceCandidate(data.value))
|
|
||||||
}
|
|
||||||
}catch(error){
|
|
||||||
debugger;
|
|
||||||
}finally{
|
|
||||||
console.log("ICE Canbet")
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "offer":{
|
|
||||||
let readyForOffer = !this.makingOffer && (this.rtc.signalingState == "stable" || this.isSettingRemoteAnswerPending);
|
|
||||||
|
|
||||||
const offerCollision = !readyForOffer;
|
|
||||||
|
|
||||||
this.ignoreOffer = !this.isPolite() && offerCollision;
|
|
||||||
|
|
||||||
if(this.ignoreOffer){
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isSettingRemoteAnswerPending = false;
|
|
||||||
|
|
||||||
await this.rtc.setRemoteDescription(new RTCSessionDescription(data.value));
|
|
||||||
|
|
||||||
this.isSettingRemoteAnswerPending = false;
|
|
||||||
|
|
||||||
for (const candidate of this.candicatePack) {
|
|
||||||
await this.rtc.addIceCandidate(candidate);
|
|
||||||
}
|
|
||||||
|
|
||||||
let answer = await this.rtc.createAnswer({
|
|
||||||
offerToReceiveAudio: true,
|
|
||||||
offerToReceiveVideo: true
|
|
||||||
})
|
|
||||||
await this.rtc.setLocalDescription(answer);
|
|
||||||
this.send({
|
|
||||||
type: 'answer',
|
|
||||||
value: answer
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "answer":{
|
|
||||||
await this.rtc.setRemoteDescription(new RTCSessionDescription(data.value))
|
|
||||||
|
|
||||||
for (const candidate of this.candicatePack) {
|
|
||||||
await this.rtc.addIceCandidate(candidate);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "streamInfo":{
|
|
||||||
let {id,value} = data;
|
|
||||||
let streamInfo = this.recaivingStream.get(id);
|
|
||||||
if(!streamInfo)
|
|
||||||
{
|
|
||||||
this.recaivingStream.set(id,value as TransferStreamInfo);
|
|
||||||
}else{
|
|
||||||
this.recaivingStream.set(id,{
|
|
||||||
...streamInfo,
|
|
||||||
...value
|
|
||||||
} as TransferStreamInfo);
|
|
||||||
}
|
|
||||||
this.send({
|
|
||||||
type:'streamAccept',
|
|
||||||
id
|
|
||||||
})
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "streamRemoved":{
|
|
||||||
let {id} = data;
|
|
||||||
this.emit('stream:stopped', this.recaivingStream.get(id));
|
|
||||||
this.recaivingStream.delete(id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "streamAccept":{
|
|
||||||
let {id} = data;
|
|
||||||
let sendingStream = this.sendingStream.get(id) as TransferStreamInfo;
|
|
||||||
let senders = [];
|
|
||||||
if(sendingStream && sendingStream.stream)
|
|
||||||
{
|
|
||||||
for (const track of sendingStream.stream.getTracks()) {
|
|
||||||
senders.push(this.rtc.addTrack(track, sendingStream.stream));
|
|
||||||
};
|
|
||||||
sendingStream.senders = senders;
|
|
||||||
}
|
|
||||||
this.emit('stream:accepted', sendingStream);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "message":{
|
|
||||||
this.emit('message', data.payload);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
public addEventListener(event:string,callback: Function){
|
|
||||||
(this.events[event] || (this.events[event]=[])).push(callback);
|
|
||||||
};
|
|
||||||
public on(event:string,callback: Function){
|
|
||||||
this.addEventListener(event, callback)
|
|
||||||
};
|
|
||||||
public async dispatch(event:string,...args:any[]) : Promise<any> {
|
|
||||||
if(this.events[event])
|
|
||||||
{
|
|
||||||
for (const callback of this.events[event])
|
|
||||||
{
|
|
||||||
await callback(...args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public async emit(event:string,...args:any[]) : Promise<any> {
|
|
||||||
await this.dispatch(event, ...args)
|
|
||||||
}
|
|
||||||
public connect()
|
|
||||||
{
|
|
||||||
if(!this.channel)
|
|
||||||
{
|
|
||||||
this.createDefaultDataChannel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public sendMessage(data: any)
|
|
||||||
{
|
|
||||||
if(data.type == ':rtcpack:')
|
|
||||||
{
|
|
||||||
throw "WebRTC Kanalında Sızma";
|
|
||||||
}
|
|
||||||
this.send({
|
|
||||||
type: 'message',
|
|
||||||
payload: data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
public createDefaultDataChannel()
|
|
||||||
{
|
|
||||||
let dt = this.rtc.createDataChannel(':default:',{
|
|
||||||
ordered: true
|
|
||||||
});
|
|
||||||
dt.addEventListener("open",()=>{
|
|
||||||
this.channel = dt;
|
|
||||||
WebRTC.channels.set(this.id, this);
|
|
||||||
this.active = true;
|
|
||||||
});
|
|
||||||
dt.addEventListener("message",({data})=>{
|
|
||||||
let pack = JSON.parse(data);
|
|
||||||
this.emit('input', pack);
|
|
||||||
})
|
|
||||||
dt.addEventListener("close",()=>{
|
|
||||||
this.channel = undefined;
|
|
||||||
this.active = false;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
public destroy()
|
|
||||||
{
|
|
||||||
this.active = false;
|
|
||||||
if(this.channel)
|
|
||||||
{
|
|
||||||
this.channel.close();
|
|
||||||
this.channel = undefined;
|
|
||||||
}
|
|
||||||
if(this.rtc)
|
|
||||||
{
|
|
||||||
this.rtc.close();
|
|
||||||
//this.rtc = undefined;
|
|
||||||
};
|
|
||||||
this.emit('disconnected');
|
|
||||||
WebRTC.channels.delete(this.id);
|
|
||||||
}
|
|
||||||
public eventDatachannel(event: RTCDataChannelEvent)
|
|
||||||
{
|
|
||||||
if(event.channel.label == ':default:'){
|
|
||||||
WebRTC.channels.set(this.id, this);
|
|
||||||
this.channel = event.channel;
|
|
||||||
this.active = true;
|
|
||||||
event.channel.addEventListener("message",({data})=>{
|
|
||||||
let pack = JSON.parse(data);
|
|
||||||
this.emit('input', pack);
|
|
||||||
})
|
|
||||||
event.channel.addEventListener("close",()=>{
|
|
||||||
this.channel = undefined;
|
|
||||||
WebRTC.channels.delete(this.id);
|
|
||||||
WebRTC.requireGC = true;
|
|
||||||
this.active = false;
|
|
||||||
})
|
|
||||||
}else{
|
|
||||||
this.emit('datachannel', event.channel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public send(data:object)
|
|
||||||
{
|
|
||||||
if(this.channel?.readyState == "open")
|
|
||||||
{
|
|
||||||
this.channel.send(JSON.stringify(data));
|
|
||||||
}else{
|
|
||||||
this.emit('output', data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public eventConnectionState()
|
|
||||||
{
|
|
||||||
this.connectionStatus = this.rtc.connectionState;
|
|
||||||
if(this.connectionStatus == 'connected')
|
|
||||||
{
|
|
||||||
if(this.active == false)
|
|
||||||
{
|
|
||||||
this.emit('connected');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if(this.connectionStatus == 'failed')
|
|
||||||
{
|
|
||||||
this.rtc.restartIce();
|
|
||||||
};
|
|
||||||
|
|
||||||
if(this.connectionStatus == "closed")
|
|
||||||
{
|
|
||||||
if(this.active)
|
|
||||||
{
|
|
||||||
this.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public eventIcecandidate(event: RTCPeerConnectionIceEvent)
|
|
||||||
{
|
|
||||||
if(event.candidate)
|
|
||||||
{
|
|
||||||
this.send({
|
|
||||||
type:'icecandidate',
|
|
||||||
value: event.candidate
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public eventICEConnectionState()
|
|
||||||
{
|
|
||||||
this.iceStatus = this.rtc.iceConnectionState;
|
|
||||||
}
|
|
||||||
public eventICEGatherinState()
|
|
||||||
{
|
|
||||||
this.gatheringStatus = this.rtc.iceGatheringState;
|
|
||||||
}
|
|
||||||
public async eventNogationNeeded()
|
|
||||||
{
|
|
||||||
try{
|
|
||||||
this.makingOffer = true;
|
|
||||||
let offer = await this.rtc.createOffer({
|
|
||||||
iceRestart: true,
|
|
||||||
offerToReceiveAudio: true,
|
|
||||||
offerToReceiveVideo: true
|
|
||||||
});
|
|
||||||
await this.rtc.setLocalDescription(offer);
|
|
||||||
this.send({
|
|
||||||
type: 'offer',
|
|
||||||
value: offer
|
|
||||||
});
|
|
||||||
}catch(error){
|
|
||||||
console.error(`Nogation Error:`, error)
|
|
||||||
}
|
|
||||||
finally{
|
|
||||||
this.makingOffer = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public eventSignalingState()
|
|
||||||
{
|
|
||||||
this.signalingStatus = this.rtc.signalingState;
|
|
||||||
}
|
|
||||||
public eventTrack(event: RTCTrackEvent)
|
|
||||||
{
|
|
||||||
let rtpRecaiver = event.receiver;
|
|
||||||
if(event.streams.length)
|
|
||||||
{
|
|
||||||
for (const stream of event.streams) {
|
|
||||||
let streamInfo = this.recaivingStream.get(stream.id) as TransferStreamInfo;
|
|
||||||
(streamInfo.recaivers || (streamInfo.recaivers = [])).push(rtpRecaiver);
|
|
||||||
if((this.recaivingStream.get(stream.id) as {stream : MediaStream | undefined}).stream == null)
|
|
||||||
{
|
|
||||||
streamInfo.stream = stream;
|
|
||||||
this.emit('stream:added', this.recaivingStream.get(stream.id));
|
|
||||||
}else{
|
|
||||||
streamInfo.stream = stream;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public sendStream(stream:MediaStream,name:string,info:{[key:string]:any}){
|
|
||||||
this.send({
|
|
||||||
type: 'streamInfo',
|
|
||||||
id: stream.id,
|
|
||||||
value: {
|
|
||||||
...info,
|
|
||||||
name: name
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.sendingStream.set(stream.id,{
|
|
||||||
...info,
|
|
||||||
id:stream.id,
|
|
||||||
name: name,
|
|
||||||
stream
|
|
||||||
} as TransferStreamInfo);
|
|
||||||
};
|
|
||||||
public stopStream(_stream:MediaStream){
|
|
||||||
if(this.connectionStatus != 'connected'){
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if(this.sendingStream.has(_stream.id))
|
|
||||||
{
|
|
||||||
let {stream} = this.sendingStream.get(_stream.id) as {stream:MediaStream};
|
|
||||||
|
|
||||||
for (const track of stream.getTracks()) {
|
|
||||||
for (const RTCPSender of this.rtc.getSenders()) {
|
|
||||||
if(RTCPSender.track?.id == track.id)
|
|
||||||
{
|
|
||||||
this.rtc.removeTrack(RTCPSender);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.send({
|
|
||||||
type: 'streamRemoved',
|
|
||||||
id: stream.id
|
|
||||||
});
|
|
||||||
this.sendingStream.delete(_stream.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public stopAllStreams()
|
|
||||||
{
|
|
||||||
if(this.connectionStatus != 'connected'){
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for (const [, {stream}] of this.sendingStream) {
|
|
||||||
if(stream == undefined)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const track of stream.getTracks()) {
|
|
||||||
for (const RTCPSender of this.rtc.getSenders()) {
|
|
||||||
if(RTCPSender.track?.id == track.id)
|
|
||||||
{
|
|
||||||
this.rtc.removeTrack(RTCPSender);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.send({
|
|
||||||
type: 'streamRemoved',
|
|
||||||
id: stream.id
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
this.sendingStream.clear();
|
|
||||||
}
|
|
||||||
public async SendFile(file:File, meta: object)
|
|
||||||
{
|
|
||||||
if(!this.peer)
|
|
||||||
{
|
|
||||||
throw new Error("Peer is not ready");
|
|
||||||
}
|
|
||||||
this.FileTransportChannel = new P2PFileSender(this, this.peer);
|
|
||||||
|
|
||||||
await this.FileTransportChannel.SendFile(file, meta);
|
|
||||||
}
|
|
||||||
public async RecaiveFile(
|
|
||||||
chnlCount:number,
|
|
||||||
filemeta: {
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
},
|
|
||||||
totalSize: number
|
|
||||||
) : Promise<File>
|
|
||||||
{
|
|
||||||
if(!this.peer)
|
|
||||||
{
|
|
||||||
throw new Error("Peer is not ready");
|
|
||||||
}
|
|
||||||
this.FileTransportChannel = new P2PFileSender(this, this.peer);
|
|
||||||
|
|
||||||
return await new Promise(recaivedFile => {
|
|
||||||
if(this.FileTransportChannel)
|
|
||||||
{
|
|
||||||
this.FileTransportChannel.RecaiveFile(
|
|
||||||
this.rtc,
|
|
||||||
filemeta,
|
|
||||||
chnlCount,
|
|
||||||
totalSize,
|
|
||||||
(file: File) => {
|
|
||||||
recaivedFile(file)
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
WebRTC.requireGC = false;
|
|
||||||
setInterval(()=>{
|
|
||||||
if(WebRTC.requireGC == false) return;
|
|
||||||
let img = document.createElement("img");
|
|
||||||
img.src = window.URL.createObjectURL(new Blob([new ArrayBuffer(5e+7)]));
|
|
||||||
img.onerror = function() {
|
|
||||||
window.URL.revokeObjectURL(this.src);
|
|
||||||
};
|
|
||||||
WebRTC.requireGC = false;
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface MediaStream {
|
|
||||||
senders : RTCRtpSender[];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,238 +0,0 @@
|
||||||
import {Connection,IConnection} from "./Connection";
|
|
||||||
import EventPool from "./EventPool";
|
|
||||||
import EventTarget from "./EventTarget";
|
|
||||||
import { IPPressure } from "./IPPressure";
|
|
||||||
import Peer from "./Peer";
|
|
||||||
import Room, { IRoomOptions } from "./Room";
|
|
||||||
import WSTSProtocol, { Message } from "./WSTSProtocol";
|
|
||||||
import WebRTC from "./WebRTC";
|
|
||||||
//import {Gzip} from "fflate";
|
|
||||||
export default class MWSE extends EventTarget {
|
|
||||||
public static rtc : WebRTC;
|
|
||||||
public server! : Connection;
|
|
||||||
public WSTSProtocol! : WSTSProtocol;
|
|
||||||
public EventPooling! : EventPool;
|
|
||||||
public rooms : Map<string, Room> = new Map();
|
|
||||||
public pairs : Map<string, Peer> = new Map();
|
|
||||||
public peers : Map<string, Peer> = new Map();
|
|
||||||
public virtualPressure : IPPressure;
|
|
||||||
public me! : Peer;
|
|
||||||
/*public static compress(message:string, callback:(e:any) => any)
|
|
||||||
{
|
|
||||||
let u : any= [];
|
|
||||||
let C = new Gzip({
|
|
||||||
level: 9,
|
|
||||||
mem: 12
|
|
||||||
},(stream,isLast) => {
|
|
||||||
u.push(stream);
|
|
||||||
if(isLast)
|
|
||||||
{
|
|
||||||
callback(u);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
C.push(new TextEncoder().encode(message), true);
|
|
||||||
}*/
|
|
||||||
constructor(options: IConnection){
|
|
||||||
super();
|
|
||||||
MWSE.rtc = MWSE as unknown as WebRTC;
|
|
||||||
this.server = new Connection(this,options);
|
|
||||||
this.WSTSProtocol = new WSTSProtocol(this);
|
|
||||||
this.EventPooling = new EventPool(this);
|
|
||||||
this.virtualPressure = new IPPressure(this);
|
|
||||||
this.server.connect();
|
|
||||||
this.me = new Peer(this);
|
|
||||||
this.me.scope(()=>{
|
|
||||||
this.peers.set('me', this.me);
|
|
||||||
this.peers.set(this.me.socketId as string, this.me);
|
|
||||||
})
|
|
||||||
this.server.onActive(async ()=>{
|
|
||||||
this.me.setSocketId('me');
|
|
||||||
await this.me.metadata();
|
|
||||||
this.emit('scope');
|
|
||||||
this.activeScope = true;
|
|
||||||
});
|
|
||||||
this.server.onPassive(async ()=>{
|
|
||||||
this.emit('close');
|
|
||||||
});
|
|
||||||
this.packMessagingSystem();
|
|
||||||
}
|
|
||||||
|
|
||||||
public writable = 1;
|
|
||||||
public readable = 1;
|
|
||||||
|
|
||||||
public destroy()
|
|
||||||
{
|
|
||||||
this.server.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
public enableRecaiveData(){
|
|
||||||
this.WSTSProtocol.SendOnly({ type: 'connection/packrecaive', value: 1 })
|
|
||||||
this.readable = 1
|
|
||||||
}
|
|
||||||
public disableRecaiveData(){
|
|
||||||
this.WSTSProtocol.SendOnly({ type: 'connection/packrecaive', value: 0 })
|
|
||||||
this.readable = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
public enableSendData(){
|
|
||||||
this.WSTSProtocol.SendOnly({ type: 'connection/packsending', value: 1 })
|
|
||||||
this.writable = 1
|
|
||||||
}
|
|
||||||
public disableSendData(){
|
|
||||||
this.WSTSProtocol.SendOnly({ type: 'connection/packsending', value: 0 })
|
|
||||||
this.writable = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
public enableNotifyRoomInfo(){
|
|
||||||
this.WSTSProtocol.SendOnly({ type: 'connection/roominfo', value: 1 })
|
|
||||||
}
|
|
||||||
public disableNotifyRoomInfo(){
|
|
||||||
this.WSTSProtocol.SendOnly({ type: 'connection/roominfo', value: 0 })
|
|
||||||
}
|
|
||||||
|
|
||||||
public async request(peerId: string, pack:Message)
|
|
||||||
{
|
|
||||||
let {pack:answer} = await this.EventPooling.request({
|
|
||||||
type: 'request/to',
|
|
||||||
to: peerId,
|
|
||||||
pack
|
|
||||||
});
|
|
||||||
return answer;
|
|
||||||
}
|
|
||||||
public async response(peerId: string, requestId:number, pack:Message)
|
|
||||||
{
|
|
||||||
this.WSTSProtocol.SendOnly({
|
|
||||||
type: 'response/to',
|
|
||||||
to: peerId,
|
|
||||||
pack,
|
|
||||||
id: requestId
|
|
||||||
})
|
|
||||||
}
|
|
||||||
private packMessagingSystem()
|
|
||||||
{
|
|
||||||
this.EventPooling.signal('pack',(payload : {from:string,pack:any}) => {
|
|
||||||
if(this.readable)
|
|
||||||
{
|
|
||||||
let {from,pack} = payload;
|
|
||||||
this.peer(from, true).emit('pack', pack);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
this.EventPooling.signal('request',(payload : {from:string,pack:any,id:number}) => {
|
|
||||||
let {from,pack, id} = payload;
|
|
||||||
let scope = {
|
|
||||||
body: pack,
|
|
||||||
response: (pack: Message) => {
|
|
||||||
this.response(from, id, pack);
|
|
||||||
},
|
|
||||||
peer: this.peer(from, true)
|
|
||||||
};
|
|
||||||
this.peer(from, true).emit('request', scope);
|
|
||||||
this.peer('me').emit('request', scope);
|
|
||||||
})
|
|
||||||
this.EventPooling.signal('pack/room',(payload : {from:string,pack:any,sender:string}) => {
|
|
||||||
if(this.readable)
|
|
||||||
{
|
|
||||||
let {from,pack,sender} = payload;
|
|
||||||
this.room(from).emit('message', pack, this.peer(sender));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
this.EventPooling.signal('room/joined',(payload : {id:string,roomid:any,ownerid:string}) => {
|
|
||||||
let {id,roomid} = payload;
|
|
||||||
let room = this.room(roomid);
|
|
||||||
let peer = this.peer(id, true);
|
|
||||||
room.peers.set(peer.socketId as string, peer);
|
|
||||||
room.emit('join', peer);
|
|
||||||
})
|
|
||||||
this.EventPooling.signal('room/info',(payload : {roomId:string,value:any,name:string}) => {
|
|
||||||
let {roomId,name,value} = payload;
|
|
||||||
this.room(roomId).emit('updateinfo', name,value);
|
|
||||||
})
|
|
||||||
this.EventPooling.signal('room/ejected',(payload : {id:string,roomid:any,ownerid:string}) => {
|
|
||||||
let {id,roomid} = payload;
|
|
||||||
let room = this.room(roomid);
|
|
||||||
let peer = this.peer(id, true);
|
|
||||||
room.peers.delete(peer.socketId as string);
|
|
||||||
room.emit('eject', peer);
|
|
||||||
})
|
|
||||||
this.EventPooling.signal('room/closed',(payload : {roomid:any}) => {
|
|
||||||
let {roomid} = payload;
|
|
||||||
let room = this.room(roomid);
|
|
||||||
room.peers.clear();
|
|
||||||
room.emit('close');
|
|
||||||
this.rooms.delete(roomid);
|
|
||||||
})
|
|
||||||
this.EventPooling.signal("pair/info", (payload : {from : string,name: string, value: string | number | boolean}) => {
|
|
||||||
let {from, name, value} = payload;
|
|
||||||
let peer = this.peer(from, true);
|
|
||||||
peer.info.info[name] = value;
|
|
||||||
peer.emit("info", name, value);
|
|
||||||
})
|
|
||||||
this.EventPooling.signal("request/pair", (payload : {from : string,info: any}) => {
|
|
||||||
let {from, info} = payload;
|
|
||||||
let peer = this.peer(from, true);
|
|
||||||
peer.info.info = info;
|
|
||||||
peer.emit("request/pair", peer);
|
|
||||||
this.peer('me').emit('request/pair', peer);
|
|
||||||
})
|
|
||||||
this.EventPooling.signal("peer/disconnect", (payload : {id : string}) => {
|
|
||||||
let {id} = payload;
|
|
||||||
let peer = this.peer(id, true);
|
|
||||||
peer.emit("disconnect", peer);
|
|
||||||
})
|
|
||||||
this.EventPooling.signal("accepted/pair", (payload : {from : string,info: any}) => {
|
|
||||||
let {from, info} = payload;
|
|
||||||
let peer = this.peer(from, true);
|
|
||||||
peer.info.info = info;
|
|
||||||
peer.emit("accepted/pair", peer);
|
|
||||||
this.peer('me').emit('accepted/pair', peer);
|
|
||||||
})
|
|
||||||
this.EventPooling.signal("end/pair", (payload : {from : string,info: any}) => {
|
|
||||||
let {from, info} = payload;
|
|
||||||
let peer = this.peer(from, true);
|
|
||||||
peer.emit("end/pair", info);
|
|
||||||
this.peer('me').emit('end/pair', from, info);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
public room(options: IRoomOptions | string) : Room
|
|
||||||
{
|
|
||||||
if(typeof options == "string")
|
|
||||||
{
|
|
||||||
if(this.rooms.has(options))
|
|
||||||
{
|
|
||||||
return this.rooms.get(options) as Room
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let room = new Room(this);
|
|
||||||
room.setRoomOptions(options);
|
|
||||||
this.emit('room');
|
|
||||||
return room;
|
|
||||||
}
|
|
||||||
public peer(options: string | IRoomOptions, isActive = false) : Peer
|
|
||||||
{
|
|
||||||
if(typeof options == "string")
|
|
||||||
{
|
|
||||||
if(this.peers.has(options))
|
|
||||||
{
|
|
||||||
return this.peers.get(options) as Peer
|
|
||||||
}
|
|
||||||
if(this.pairs.has(options))
|
|
||||||
{
|
|
||||||
return this.pairs.get(options) as Peer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let peer = new Peer(this);
|
|
||||||
peer.setPeerOptions(options);
|
|
||||||
peer.active = isActive;
|
|
||||||
this.peers.set(peer.socketId as string, peer);
|
|
||||||
this.emit('peer', peer);
|
|
||||||
return peer;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
MWSE: any;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.MWSE = MWSE;
|
|
||||||
|
|
@ -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=
|
||||||
14
index.js
14
index.js
|
|
@ -1,14 +0,0 @@
|
||||||
require("./Source/index");
|
|
||||||
|
|
||||||
process.on('unhandledRejection',(reason, promise)=>{
|
|
||||||
console.log("Process unhandledRejection",{reason, promise})
|
|
||||||
});
|
|
||||||
process.on('rejectionHandled',(promise)=>{
|
|
||||||
console.log("Process rejectionHandled",{promise})
|
|
||||||
});
|
|
||||||
process.on('multipleResolves',(type, promise, value)=>{
|
|
||||||
console.log("Process multipleResolves",{type, promise, value})
|
|
||||||
});
|
|
||||||
process.on('warning',(err)=>{
|
|
||||||
console.log("Process warning", err)
|
|
||||||
});
|
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
// Package bridge implements the 3rd-party server bridge (#46): it lets an external
|
||||||
|
// application server talk to MWSE over plain HTTPS (get/post) without speaking
|
||||||
|
// WebSocket, and lets MWSE delegate connection approval to that application
|
||||||
|
// server.
|
||||||
|
//
|
||||||
|
// Three pieces, each independently testable:
|
||||||
|
//
|
||||||
|
// - Inbox : a bounded queue of client->application messages the app drains
|
||||||
|
// by polling an HTTP endpoint.
|
||||||
|
// - HTTPApprover : asks the application "Connect?" for each new client and accepts
|
||||||
|
// only on an explicit approval (fail-closed).
|
||||||
|
// - HTTPTrigger : pushes a suit-notification reply (#44) to the application,
|
||||||
|
// so the app is told the moment a reply arrives instead of polling.
|
||||||
|
package bridge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.saqut.com/saqut/mwse/internal/notify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message is one client->application message held in the inbox.
|
||||||
|
type Message struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
Pack any `json:"pack"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inbox is a bounded FIFO of messages awaiting collection by the application
|
||||||
|
// server. It is bounded so a never-polling application cannot make it grow without
|
||||||
|
// limit (oldest messages are dropped first).
|
||||||
|
type Inbox struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
q []Message
|
||||||
|
max int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInbox returns an inbox holding up to max messages (<=0 uses 10000).
|
||||||
|
func NewInbox(max int) *Inbox {
|
||||||
|
if max <= 0 {
|
||||||
|
max = 10000
|
||||||
|
}
|
||||||
|
return &Inbox{max: max}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push appends a message from a client, dropping the oldest if the cap is hit.
|
||||||
|
func (i *Inbox) Push(from string, pack any) {
|
||||||
|
i.mu.Lock()
|
||||||
|
defer i.mu.Unlock()
|
||||||
|
if len(i.q) >= i.max {
|
||||||
|
drop := len(i.q) - i.max + 1
|
||||||
|
i.q = i.q[drop:]
|
||||||
|
}
|
||||||
|
i.q = append(i.q, Message{From: from, Pack: pack, At: time.Now()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain returns and clears all queued messages.
|
||||||
|
func (i *Inbox) Drain() []Message {
|
||||||
|
i.mu.Lock()
|
||||||
|
defer i.mu.Unlock()
|
||||||
|
if len(i.q) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := i.q
|
||||||
|
i.q = nil
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len reports how many messages are queued.
|
||||||
|
func (i *Inbox) Len() int {
|
||||||
|
i.mu.Lock()
|
||||||
|
defer i.mu.Unlock()
|
||||||
|
return len(i.q)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPApprover delegates connection approval to an application server. For each
|
||||||
|
// new client it POSTs {id, meta} to URL and accepts only if the response is 200
|
||||||
|
// with a JSON body {"approve": true}. Any error (unreachable app, non-200,
|
||||||
|
// malformed body) denies the connection — fail-closed, matching "approves or it
|
||||||
|
// is rejected".
|
||||||
|
type HTTPApprover struct {
|
||||||
|
URL string
|
||||||
|
Client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTTPApprover builds an approver with a sensible request timeout.
|
||||||
|
func NewHTTPApprover(url string, timeout time.Duration) *HTTPApprover {
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 3 * time.Second
|
||||||
|
}
|
||||||
|
return &HTTPApprover{URL: url, Client: &http.Client{Timeout: timeout}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approve implements ws.Approver.
|
||||||
|
func (a *HTTPApprover) Approve(id string, meta map[string]any) bool {
|
||||||
|
body, _ := json.Marshal(map[string]any{"id": id, "meta": meta})
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, a.URL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := a.Client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Approve bool `json:"approve"`
|
||||||
|
}
|
||||||
|
if json.NewDecoder(resp.Body).Decode(&out) != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return out.Approve
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPTrigger pushes suit-notification replies (#44) to the application server.
|
||||||
|
// It structurally satisfies services.NotifyTrigger.
|
||||||
|
type HTTPTrigger struct {
|
||||||
|
URL string
|
||||||
|
Client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTTPTrigger builds a trigger with a sensible request timeout.
|
||||||
|
func NewHTTPTrigger(url string, timeout time.Duration) *HTTPTrigger {
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 3 * time.Second
|
||||||
|
}
|
||||||
|
return &HTTPTrigger{URL: url, Client: &http.Client{Timeout: timeout}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyReplied posts the reply to the application server (best effort).
|
||||||
|
func (t *HTTPTrigger) NotifyReplied(n notify.Notification) {
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"trace": n.Trace,
|
||||||
|
"from": n.From,
|
||||||
|
"to": n.To,
|
||||||
|
"reply": n.Reply,
|
||||||
|
})
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, t.URL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if resp, err := t.Client.Do(req); err == nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
package bridge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.saqut.com/saqut/mwse/internal/notify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Inbox ---------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestInboxPushDrain(t *testing.T) {
|
||||||
|
inbox := NewInbox(0)
|
||||||
|
inbox.Push("a", "hello")
|
||||||
|
inbox.Push("b", 42)
|
||||||
|
|
||||||
|
msgs := inbox.Drain()
|
||||||
|
if len(msgs) != 2 {
|
||||||
|
t.Fatalf("drain = %d, want 2", len(msgs))
|
||||||
|
}
|
||||||
|
if msgs[0].From != "a" || msgs[1].From != "b" {
|
||||||
|
t.Fatalf("from order wrong: %v", msgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain again should return nil (empty).
|
||||||
|
if got := inbox.Drain(); got != nil {
|
||||||
|
t.Fatalf("second drain = %v, want nil", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboxCapDropsOldest(t *testing.T) {
|
||||||
|
inbox := NewInbox(3)
|
||||||
|
for i := range 5 {
|
||||||
|
inbox.Push("x", i)
|
||||||
|
}
|
||||||
|
if inbox.Len() != 3 {
|
||||||
|
t.Fatalf("len = %d, want 3 (capped)", inbox.Len())
|
||||||
|
}
|
||||||
|
msgs := inbox.Drain()
|
||||||
|
// Should contain the last 3 items: 2, 3, 4.
|
||||||
|
if msgs[0].Pack.(int) != 2 {
|
||||||
|
t.Fatalf("oldest surviving item = %v, want 2", msgs[0].Pack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboxLen(t *testing.T) {
|
||||||
|
inbox := NewInbox(0)
|
||||||
|
if inbox.Len() != 0 {
|
||||||
|
t.Fatalf("initial len = %d, want 0", inbox.Len())
|
||||||
|
}
|
||||||
|
inbox.Push("a", nil)
|
||||||
|
if inbox.Len() != 1 {
|
||||||
|
t.Fatalf("after push len = %d, want 1", inbox.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- HTTPApprover --------------------------------------------------------
|
||||||
|
|
||||||
|
func TestHTTPApproverApproves(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body map[string]any
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
if body["id"] == nil {
|
||||||
|
http.Error(w, "missing id", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"approve": true})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
a := NewHTTPApprover(srv.URL, 2*time.Second)
|
||||||
|
if !a.Approve("client-1", map[string]any{"ip": "1.2.3.4"}) {
|
||||||
|
t.Fatal("expected approve=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPApproverRejects(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"approve": false})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
a := NewHTTPApprover(srv.URL, 2*time.Second)
|
||||||
|
if a.Approve("client-2", nil) {
|
||||||
|
t.Fatal("expected approve=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPApproverFailClosed(t *testing.T) {
|
||||||
|
// Unreachable URL must deny (fail-closed).
|
||||||
|
a := NewHTTPApprover("http://127.0.0.1:1", 100*time.Millisecond)
|
||||||
|
if a.Approve("x", nil) {
|
||||||
|
t.Fatal("expected fail-closed denial for unreachable server")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPApproverNon200Denies(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
a := NewHTTPApprover(srv.URL, 2*time.Second)
|
||||||
|
if a.Approve("c", nil) {
|
||||||
|
t.Fatal("expected denial on non-200")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- HTTPTrigger ---------------------------------------------------------
|
||||||
|
|
||||||
|
func TestHTTPTriggerPosts(t *testing.T) {
|
||||||
|
received := make(chan map[string]any, 1)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body map[string]any
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
received <- body
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
trigger := NewHTTPTrigger(srv.URL, 2*time.Second)
|
||||||
|
n := notify.Notification{
|
||||||
|
Trace: "tr1",
|
||||||
|
From: "alice",
|
||||||
|
To: "bob",
|
||||||
|
Reply: map[string]any{"ok": true},
|
||||||
|
}
|
||||||
|
trigger.NotifyReplied(n)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case body := <-received:
|
||||||
|
if body["trace"] != "tr1" || body["from"] != "alice" {
|
||||||
|
t.Fatalf("received body = %v", body)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("trigger post not received within 2s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPTriggerBestEffortOnError(t *testing.T) {
|
||||||
|
// An unreachable trigger must not panic or block.
|
||||||
|
trigger := NewHTTPTrigger("http://127.0.0.1:1", 100*time.Millisecond)
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
trigger.NotifyReplied(notify.Notification{Trace: "x"})
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("trigger blocked on error instead of returning quickly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
// 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 served at /<file> (default "./public")
|
||||||
|
SDKDir string // ES-module SDK files served at /sdk/ (default "./sdk")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// --- scale / throughput knobs (defaults kept deliberately high) ---------
|
||||||
|
//
|
||||||
|
// The engine is built for very high connection counts and endless message
|
||||||
|
// traffic, so these are tuned generous. They are env-overridable both up (more
|
||||||
|
// headroom) and down (tighter memory on small hosts). See the memory note on
|
||||||
|
// OutboundBuffer below.
|
||||||
|
Conn ConnConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnConfig groups per-connection transport tuning.
|
||||||
|
type ConnConfig struct {
|
||||||
|
// OutboundBuffer is how many frames may queue for one client before further
|
||||||
|
// frames are dropped (best-effort relay; the connection is kept alive).
|
||||||
|
//
|
||||||
|
// Memory note: the channel backing this is preallocated per connection at
|
||||||
|
// ~24 bytes/slot, so the default 1024 costs ~24 KiB/connection (~2.4 GiB at
|
||||||
|
// 100k connections) even while idle. Lower MWSE_OUTBOUND_BUFFER on memory-
|
||||||
|
// constrained hosts; raise it to tolerate burstier producers.
|
||||||
|
OutboundBuffer int
|
||||||
|
|
||||||
|
// MaxMessageSize caps a single inbound frame. High by default to support the
|
||||||
|
// large tunneled payloads used for file transfer (#30).
|
||||||
|
MaxMessageSize int64
|
||||||
|
|
||||||
|
ReadBufferSize int // gorilla per-connection read buffer
|
||||||
|
WriteBufferSize int // gorilla write buffer size (pooled across connections)
|
||||||
|
|
||||||
|
PingInterval time.Duration // heartbeat period (the original used 10s)
|
||||||
|
PongWait time.Duration // how long to wait for a pong before dropping
|
||||||
|
WriteWait time.Duration // deadline for a single socket write
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads configuration from the environment. Recognised variables:
|
||||||
|
//
|
||||||
|
// MWSE_HOST, MWSE_PORT, MWSE_PUBLIC_DIR, MWSE_SDK_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"),
|
||||||
|
SDKDir: env("MWSE_SDK_DIR", "./sdk"),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
ShutdownTimeout: time.Duration(envInt("MWSE_SHUTDOWN_TIMEOUT", 10)) * time.Second,
|
||||||
|
TermOutput: envBool("MWSE_TERM_OUTPUT", false),
|
||||||
|
Conn: ConnConfig{
|
||||||
|
OutboundBuffer: envInt("MWSE_OUTBOUND_BUFFER", 1024),
|
||||||
|
MaxMessageSize: int64(envInt("MWSE_MAX_MESSAGE_SIZE", 16<<20)),
|
||||||
|
ReadBufferSize: envInt("MWSE_READ_BUFFER", 4096),
|
||||||
|
WriteBufferSize: envInt("MWSE_WRITE_BUFFER", 4096),
|
||||||
|
PingInterval: time.Duration(envInt("MWSE_PING_INTERVAL", 10)) * time.Second,
|
||||||
|
PongWait: time.Duration(envInt("MWSE_PONG_WAIT", 60)) * time.Second,
|
||||||
|
WriteWait: time.Duration(envInt("MWSE_WRITE_WAIT", 10)) * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConnConfig returns the same connection tuning Load would produce from a
|
||||||
|
// clean environment. Useful for tests and for ws.NewServer callers that do not
|
||||||
|
// load the full Config.
|
||||||
|
func DefaultConnConfig() ConnConfig {
|
||||||
|
return ConnConfig{
|
||||||
|
OutboundBuffer: 1024,
|
||||||
|
MaxMessageSize: 16 << 20,
|
||||||
|
ReadBufferSize: 4096,
|
||||||
|
WriteBufferSize: 4096,
|
||||||
|
PingInterval: 10 * time.Second,
|
||||||
|
PongWait: 60 * time.Second,
|
||||||
|
WriteWait: 10 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,279 @@
|
||||||
|
// Package datastore implements the shared data layer of #45: server-authoritative
|
||||||
|
// collections that clients mutate with CRUD operations (active sync) and a fast
|
||||||
|
// merge pool that clients converge through (passive sync), plus temp/permanent
|
||||||
|
// datastores addressable by a public id.
|
||||||
|
//
|
||||||
|
// The package is deliberately free of any transport dependency: it owns the data
|
||||||
|
// and the conflict rules, and returns the subscriber ids that a mutation must be
|
||||||
|
// broadcast to. The service layer (internal/services) resolves those ids to
|
||||||
|
// connections and emits the signals. That split keeps the data rules unit-testable
|
||||||
|
// without a hub or a socket.
|
||||||
|
//
|
||||||
|
// # Conflict resolution
|
||||||
|
//
|
||||||
|
// Every mutation is serialized through the datastore's lock and stamped with a
|
||||||
|
// monotonically increasing sequence number. "Arrival-time priority" (the rule the
|
||||||
|
// issue asks for) therefore falls out naturally: concurrent writes are ordered by
|
||||||
|
// the moment they acquire the lock, and the last arrival wins. The sequence number
|
||||||
|
// rides along on every broadcast so clients converge to the same state.
|
||||||
|
package datastore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kind selects the lifetime of a datastore.
|
||||||
|
type Kind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Temp Kind = "temp" // expires after a TTL (default)
|
||||||
|
Permanent Kind = "permanent" // never expires
|
||||||
|
)
|
||||||
|
|
||||||
|
// Record is one row of a collection, keyed by the value of the datastore's
|
||||||
|
// primary field.
|
||||||
|
type Record struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Data map[string]any `json:"data"`
|
||||||
|
Seq uint64 `json:"seq"`
|
||||||
|
Updated time.Time `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datastore is a server-authoritative shared table.
|
||||||
|
type Datastore struct {
|
||||||
|
ID string
|
||||||
|
Kind Kind
|
||||||
|
Primary string // primary key field name (default "id")
|
||||||
|
ExpiresAt time.Time
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
records map[string]*Record
|
||||||
|
subscribers map[string]struct{}
|
||||||
|
seq uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Datastore) expired(now time.Time) bool {
|
||||||
|
return !d.ExpiresAt.IsZero() && now.After(d.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe registers a client id as interested in this datastore's broadcasts.
|
||||||
|
func (d *Datastore) Subscribe(clientID string) {
|
||||||
|
d.mu.Lock()
|
||||||
|
d.subscribers[clientID] = struct{}{}
|
||||||
|
d.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe removes a client id.
|
||||||
|
func (d *Datastore) Unsubscribe(clientID string) {
|
||||||
|
d.mu.Lock()
|
||||||
|
delete(d.subscribers, clientID)
|
||||||
|
d.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribers returns the current subscriber ids.
|
||||||
|
func (d *Datastore) Subscribers() []string {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
out := make([]string, 0, len(d.subscribers))
|
||||||
|
for id := range d.subscribers {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set upserts a record and returns it stamped with a fresh sequence number. The
|
||||||
|
// primary key is read from data[Primary]; if absent or not a string, ok is false.
|
||||||
|
func (d *Datastore) Set(data map[string]any, now time.Time) (Record, bool) {
|
||||||
|
key, ok := data[d.Primary].(string)
|
||||||
|
if !ok || key == "" {
|
||||||
|
return Record{}, false
|
||||||
|
}
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
d.seq++
|
||||||
|
rec := &Record{Key: key, Data: data, Seq: d.seq, Updated: now}
|
||||||
|
d.records[key] = rec
|
||||||
|
return *rec, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a record, returning the sequence number of the delete op (so the
|
||||||
|
// broadcast can be ordered against sets) and whether a record existed.
|
||||||
|
func (d *Datastore) Delete(key string) (uint64, bool) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if _, ok := d.records[key]; !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
d.seq++
|
||||||
|
delete(d.records, key)
|
||||||
|
return d.seq, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a copy of one record.
|
||||||
|
func (d *Datastore) Get(key string) (Record, bool) {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
rec, ok := d.records[key]
|
||||||
|
if !ok {
|
||||||
|
return Record{}, false
|
||||||
|
}
|
||||||
|
return *rec, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns all records ordered by sequence number (insertion/update
|
||||||
|
// order), which is the order a freshly-opening client should apply them in.
|
||||||
|
func (d *Datastore) Snapshot() []Record {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
out := make([]Record, 0, len(d.records))
|
||||||
|
for _, rec := range d.records {
|
||||||
|
out = append(out, *rec)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Seq < out[j].Seq })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store owns all named datastores (active sync) and merge pools (passive sync).
|
||||||
|
type Store struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
stores map[string]*Datastore
|
||||||
|
pools map[string]*Pool
|
||||||
|
now func() time.Time
|
||||||
|
ttl time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStore returns an empty store. Temp datastores and pools default to a 1h TTL.
|
||||||
|
func NewStore() *Store {
|
||||||
|
return &Store{
|
||||||
|
stores: make(map[string]*Datastore),
|
||||||
|
pools: make(map[string]*Pool),
|
||||||
|
now: time.Now,
|
||||||
|
ttl: time.Hour,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetClock replaces the time source; intended for deterministic tests.
|
||||||
|
func (s *Store) SetClock(now func() time.Time) { s.now = now }
|
||||||
|
|
||||||
|
// Open returns the datastore with the given id, creating it if absent. An empty
|
||||||
|
// id is replaced with a fresh public id. An empty primary defaults to "id". A
|
||||||
|
// zero ttl on a temp datastore uses the store default; permanent ignores ttl.
|
||||||
|
func (s *Store) Open(id string, kind Kind, primary string, ttl time.Duration) *Datastore {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if id == "" {
|
||||||
|
id = newID()
|
||||||
|
}
|
||||||
|
if existing, ok := s.stores[id]; ok {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
if primary == "" {
|
||||||
|
primary = "id"
|
||||||
|
}
|
||||||
|
if kind != Permanent {
|
||||||
|
kind = Temp
|
||||||
|
}
|
||||||
|
d := &Datastore{
|
||||||
|
ID: id,
|
||||||
|
Kind: kind,
|
||||||
|
Primary: primary,
|
||||||
|
records: make(map[string]*Record),
|
||||||
|
subscribers: make(map[string]struct{}),
|
||||||
|
}
|
||||||
|
if kind == Temp {
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = s.ttl
|
||||||
|
}
|
||||||
|
d.ExpiresAt = s.now().Add(ttl)
|
||||||
|
}
|
||||||
|
s.stores[id] = d
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns an existing, non-expired datastore.
|
||||||
|
func (s *Store) Get(id string) (*Datastore, bool) {
|
||||||
|
now := s.now()
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
d, ok := s.stores[id]
|
||||||
|
if !ok || d.expired(now) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return d, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnsubscribeAll drops a client id from every datastore and pool. Called on
|
||||||
|
// disconnect so a departed client leaves no residual subscription.
|
||||||
|
func (s *Store) UnsubscribeAll(clientID string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
stores := make([]*Datastore, 0, len(s.stores))
|
||||||
|
for _, d := range s.stores {
|
||||||
|
stores = append(stores, d)
|
||||||
|
}
|
||||||
|
pools := make([]*Pool, 0, len(s.pools))
|
||||||
|
for _, p := range s.pools {
|
||||||
|
pools = append(pools, p)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
for _, d := range stores {
|
||||||
|
d.Unsubscribe(clientID)
|
||||||
|
}
|
||||||
|
for _, p := range pools {
|
||||||
|
p.Unsubscribe(clientID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeExpired removes expired temp datastores and pools, returning the total
|
||||||
|
// number removed.
|
||||||
|
func (s *Store) PurgeExpired() int {
|
||||||
|
now := s.now()
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
removed := 0
|
||||||
|
for id, d := range s.stores {
|
||||||
|
if d.expired(now) {
|
||||||
|
delete(s.stores, id)
|
||||||
|
removed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id, p := range s.pools {
|
||||||
|
if p.expired(now) {
|
||||||
|
delete(s.pools, id)
|
||||||
|
removed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartJanitor purges expired temp datastores every interval until stop is called.
|
||||||
|
func (s *Store) StartJanitor(interval time.Duration) (stop func()) {
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(interval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-t.C:
|
||||||
|
s.PurgeExpired()
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
var once sync.Once
|
||||||
|
return func() { once.Do(func() { close(done) }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now exposes the store clock to the service layer so record timestamps share it.
|
||||||
|
func (s *Store) Now() time.Time { return s.now() }
|
||||||
|
|
||||||
|
func newID() string {
|
||||||
|
var b [12]byte
|
||||||
|
_, _ = rand.Read(b[:])
|
||||||
|
return hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
package datastore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDatastoreSetGetSnapshot(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
d := s.Open("notes", Temp, "id", 0)
|
||||||
|
|
||||||
|
r1, ok := d.Set(map[string]any{"id": "a", "v": 1}, s.Now())
|
||||||
|
if !ok || r1.Seq != 1 {
|
||||||
|
t.Fatalf("first set = %+v ok=%v", r1, ok)
|
||||||
|
}
|
||||||
|
r2, _ := d.Set(map[string]any{"id": "b", "v": 2}, s.Now())
|
||||||
|
if r2.Seq != 2 {
|
||||||
|
t.Fatalf("second set seq = %d, want 2", r2.Seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update of a existing key gets a fresh (higher) seq — last write wins.
|
||||||
|
r1b, _ := d.Set(map[string]any{"id": "a", "v": 99}, s.Now())
|
||||||
|
if r1b.Seq <= r2.Seq {
|
||||||
|
t.Fatalf("update seq = %d, want > %d", r1b.Seq, r2.Seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := d.Get("a")
|
||||||
|
if !ok || got.Data["v"] != 99 {
|
||||||
|
t.Fatalf("get a = %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
snap := d.Snapshot()
|
||||||
|
if len(snap) != 2 {
|
||||||
|
t.Fatalf("snapshot len = %d, want 2", len(snap))
|
||||||
|
}
|
||||||
|
// Snapshot is ordered by seq; the updated "a" now sorts last.
|
||||||
|
if snap[len(snap)-1].Key != "a" {
|
||||||
|
t.Fatalf("snapshot order wrong: %+v", snap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDatastoreSetRequiresPrimary(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
d := s.Open("x", Temp, "id", 0)
|
||||||
|
if _, ok := d.Set(map[string]any{"noid": true}, s.Now()); ok {
|
||||||
|
t.Fatal("set without the primary key should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDatastoreDelete(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
d := s.Open("x", Temp, "id", 0)
|
||||||
|
d.Set(map[string]any{"id": "a"}, s.Now())
|
||||||
|
|
||||||
|
seq, ok := d.Delete("a")
|
||||||
|
if !ok || seq == 0 {
|
||||||
|
t.Fatalf("delete = seq %d ok %v", seq, ok)
|
||||||
|
}
|
||||||
|
if _, ok := d.Get("a"); ok {
|
||||||
|
t.Fatal("record should be gone after delete")
|
||||||
|
}
|
||||||
|
if _, ok := d.Delete("a"); ok {
|
||||||
|
t.Fatal("deleting a missing key should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrentSetsResolveByArrival is the #45 conflict-resolution guarantee:
|
||||||
|
// concurrent writes are serialized by the datastore lock, every write gets a
|
||||||
|
// unique monotonic seq, and the final value is whichever write arrived last.
|
||||||
|
func TestConcurrentSetsResolveByArrival(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
d := s.Open("race", Temp, "id", 0)
|
||||||
|
|
||||||
|
const n = 200
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
seqs := make([]uint64, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
rec, _ := d.Set(map[string]any{"id": "k", "writer": i}, s.Now())
|
||||||
|
seqs[i] = rec.Seq
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// All seqs unique and within [1,n].
|
||||||
|
seen := make(map[uint64]bool, n)
|
||||||
|
var max uint64
|
||||||
|
for _, sq := range seqs {
|
||||||
|
if sq == 0 || sq > n || seen[sq] {
|
||||||
|
t.Fatalf("bad/duplicate seq %d", sq)
|
||||||
|
}
|
||||||
|
seen[sq] = true
|
||||||
|
if sq > max {
|
||||||
|
max = sq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The surviving record must be the one with the highest seq (last arrival).
|
||||||
|
final, _ := d.Get("k")
|
||||||
|
if final.Seq != max {
|
||||||
|
t.Fatalf("final record seq = %d, want max %d", final.Seq, max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTempExpiryAndPurge(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
now := time.Unix(0, 0)
|
||||||
|
s.SetClock(func() time.Time { return now })
|
||||||
|
|
||||||
|
s.Open("temp1", Temp, "id", time.Second)
|
||||||
|
s.Open("perm1", Permanent, "id", 0)
|
||||||
|
|
||||||
|
now = now.Add(2 * time.Second)
|
||||||
|
if _, ok := s.Get("temp1"); ok {
|
||||||
|
t.Fatal("temp store should have expired")
|
||||||
|
}
|
||||||
|
if _, ok := s.Get("perm1"); !ok {
|
||||||
|
t.Fatal("permanent store must not expire")
|
||||||
|
}
|
||||||
|
if removed := s.PurgeExpired(); removed != 1 {
|
||||||
|
t.Fatalf("purge removed %d, want 1", removed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenIsIdempotentAndGeneratesID(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
a := s.Open("", Temp, "", 0)
|
||||||
|
if a.ID == "" {
|
||||||
|
t.Fatal("empty id should be replaced with a generated one")
|
||||||
|
}
|
||||||
|
if a.Primary != "id" {
|
||||||
|
t.Fatalf("default primary = %q, want id", a.Primary)
|
||||||
|
}
|
||||||
|
b := s.Open(a.ID, Temp, "", 0)
|
||||||
|
if a != b {
|
||||||
|
t.Fatal("opening an existing id should return the same datastore")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoolMergeDedupesAndTracksDelta(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
p := s.OpenPool("p1", 0)
|
||||||
|
_ = p
|
||||||
|
|
||||||
|
added, _, ok := s.PushPool("p1", []any{"x", "y", "x"})
|
||||||
|
if !ok || len(added) != 2 {
|
||||||
|
t.Fatalf("first push added %v (ok=%v), want 2 unique", added, ok)
|
||||||
|
}
|
||||||
|
// Re-pushing existing items adds nothing.
|
||||||
|
added, _, _ = s.PushPool("p1", []any{"x", "y"})
|
||||||
|
if len(added) != 0 {
|
||||||
|
t.Fatalf("re-push added %v, want 0", added)
|
||||||
|
}
|
||||||
|
// A new item is the only delta.
|
||||||
|
added, _, _ = s.PushPool("p1", []any{"x", "z"})
|
||||||
|
if len(added) != 1 || added[0] != "z" {
|
||||||
|
t.Fatalf("push delta = %v, want [z]", added)
|
||||||
|
}
|
||||||
|
|
||||||
|
pool, _ := s.GetPool("p1")
|
||||||
|
if len(pool.Items()) != 3 {
|
||||||
|
t.Fatalf("pool size = %d, want 3", len(pool.Items()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnsubscribeAllClearsEverywhere(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
d := s.Open("d", Temp, "id", 0)
|
||||||
|
p := s.OpenPool("p", 0)
|
||||||
|
d.Subscribe("alice")
|
||||||
|
p.Subscribe("alice")
|
||||||
|
d.Subscribe("bob")
|
||||||
|
|
||||||
|
s.UnsubscribeAll("alice")
|
||||||
|
|
||||||
|
if len(d.Subscribers()) != 1 || d.Subscribers()[0] != "bob" {
|
||||||
|
t.Fatalf("datastore subscribers = %v, want [bob]", d.Subscribers())
|
||||||
|
}
|
||||||
|
if len(p.Subscribers()) != 0 {
|
||||||
|
t.Fatalf("pool subscribers = %v, want empty", p.Subscribers())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
package datastore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pool is the passive-sync primitive (#45 part 1): a shared set of unique items
|
||||||
|
// that clients converge by pushing their local items and pulling the merged
|
||||||
|
// result. Items are deduplicated by the hash of their canonical JSON, so pushing
|
||||||
|
// the same item from many clients adds it exactly once. Insertion order is kept so
|
||||||
|
// every client observes the pool in the same order.
|
||||||
|
type Pool struct {
|
||||||
|
ID string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
|
||||||
|
items map[string]any // contentHash -> item
|
||||||
|
order []string // contentHashes in insertion order
|
||||||
|
subscribers map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pool) expired(now time.Time) bool {
|
||||||
|
return !p.ExpiresAt.IsZero() && now.After(p.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe / Unsubscribe / Subscribers mirror Datastore's, guarded by the
|
||||||
|
// owning Store's lock (pool mutation always happens under that lock).
|
||||||
|
func (p *Pool) Subscribe(clientID string) { p.subscribers[clientID] = struct{}{} }
|
||||||
|
func (p *Pool) Unsubscribe(clientID string) { delete(p.subscribers, clientID) }
|
||||||
|
func (p *Pool) Subscribers() []string {
|
||||||
|
out := make([]string, 0, len(p.subscribers))
|
||||||
|
for id := range p.subscribers {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Items returns the merged pool in insertion order.
|
||||||
|
func (p *Pool) Items() []any {
|
||||||
|
out := make([]any, 0, len(p.order))
|
||||||
|
for _, h := range p.order {
|
||||||
|
out = append(out, p.items[h])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// merge adds items not already present, returning only the newly added ones (so
|
||||||
|
// the caller broadcasts the minimal delta). Convergence is reached when a client
|
||||||
|
// has pushed and pulled until no side has new items.
|
||||||
|
func (p *Pool) merge(items []any) []any {
|
||||||
|
added := make([]any, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
h := contentHash(it)
|
||||||
|
if _, ok := p.items[h]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.items[h] = it
|
||||||
|
p.order = append(p.order, h)
|
||||||
|
added = append(added, it)
|
||||||
|
}
|
||||||
|
return added
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenPool returns the pool with the given id, creating it if absent. An empty id
|
||||||
|
// is replaced with a fresh public id; a zero ttl uses the store default. Pools are
|
||||||
|
// always temporary (passive sync is for fast, ephemeral convergence).
|
||||||
|
func (s *Store) OpenPool(id string, ttl time.Duration) *Pool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if id == "" {
|
||||||
|
id = newID()
|
||||||
|
}
|
||||||
|
if p, ok := s.pools[id]; ok {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = s.ttl
|
||||||
|
}
|
||||||
|
p := &Pool{
|
||||||
|
ID: id,
|
||||||
|
ExpiresAt: s.now().Add(ttl),
|
||||||
|
items: make(map[string]any),
|
||||||
|
subscribers: make(map[string]struct{}),
|
||||||
|
}
|
||||||
|
s.pools[id] = p
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPool returns an existing, non-expired pool.
|
||||||
|
func (s *Store) GetPool(id string) (*Pool, bool) {
|
||||||
|
now := s.now()
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
p, ok := s.pools[id]
|
||||||
|
if !ok || p.expired(now) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushPool merges items into the pool under the store lock and returns the newly
|
||||||
|
// added items plus the current subscriber list for broadcasting.
|
||||||
|
func (s *Store) PushPool(id string, items []any) (added []any, subscribers []string, ok bool) {
|
||||||
|
now := s.now()
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
p, exists := s.pools[id]
|
||||||
|
if !exists || p.expired(now) {
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
return p.merge(items), p.Subscribers(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// contentHash is the dedup key: the SHA-256 of the item's canonical JSON.
|
||||||
|
func contentHash(item any) string {
|
||||||
|
b, err := json.Marshal(item)
|
||||||
|
if err != nil {
|
||||||
|
// Non-serialisable items fall back to a per-call unique key (never deduped).
|
||||||
|
return newID()
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(b)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,237 @@
|
||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.saqut.com/saqut/mwse/internal/bridge"
|
||||||
|
"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).
|
||||||
|
//
|
||||||
|
// When bridgeInbox is non-nil, the bridge drain endpoint is also registered:
|
||||||
|
// - POST /api/bridge/inbox — drain all queued client→app messages (#46)
|
||||||
|
func registerAPI(mux *http.ServeMux, hub *ws.Hub, bridgeInbox *bridge.Inbox) {
|
||||||
|
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)})
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Bridge endpoints (#46) — only registered when the inbox is configured.
|
||||||
|
if bridgeInbox != nil {
|
||||||
|
// POST /api/bridge/inbox drains all queued client→app messages atomically.
|
||||||
|
// The application server polls this to receive messages sent via bridge/send.
|
||||||
|
mux.HandleFunc("POST /api/bridge/inbox", keys.auth(func(w http.ResponseWriter, r *http.Request, domain string) {
|
||||||
|
msgs := bridgeInbox.Drain()
|
||||||
|
if msgs == nil {
|
||||||
|
msgs = []bridge.Message{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"status": "success", "messages": msgs})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,300 @@
|
||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"git.saqut.com/saqut/mwse/internal/config"
|
||||||
|
"git.saqut.com/saqut/mwse/internal/services"
|
||||||
|
"git.saqut.com/saqut/mwse/internal/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file is the #32 acceptance harness: it boots the *real* engine (hub +
|
||||||
|
// services + HTTP surface) over a *real* WebSocket and speaks the exact WSTS
|
||||||
|
// frames the TypeScript SDK in ./frontend emits, asserting the replies match the
|
||||||
|
// shapes the SDK destructures. It is the browser-free proof that the frozen I/O
|
||||||
|
// contract holds end-to-end against the Go engine.
|
||||||
|
|
||||||
|
// testEngine starts the full HTTP handler on an httptest server and returns its
|
||||||
|
// ws:// URL plus a cleanup. PingInterval is shortened so the heartbeat test does
|
||||||
|
// not wait the production 10s.
|
||||||
|
func testEngine(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
hub := ws.NewHub()
|
||||||
|
services.Register(hub)
|
||||||
|
|
||||||
|
cfg := config.Load()
|
||||||
|
cfg.Conn.PingInterval = 80 * time.Millisecond
|
||||||
|
cfg.SDKDir = t.TempDir() // static routes are irrelevant here
|
||||||
|
cfg.PublicDir = t.TempDir()
|
||||||
|
|
||||||
|
srv := httptest.NewServer(New(hub, cfg).Handler)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||||
|
}
|
||||||
|
|
||||||
|
// sdkConn is a minimal SDK-shaped WebSocket client speaking WSTS frames.
|
||||||
|
type sdkConn struct {
|
||||||
|
t *testing.T
|
||||||
|
conn *websocket.Conn
|
||||||
|
ping chan string // payloads of received server pings
|
||||||
|
}
|
||||||
|
|
||||||
|
func dial(t *testing.T, url string) *sdkConn {
|
||||||
|
t.Helper()
|
||||||
|
c, _, err := websocket.DefaultDialer.Dial(url, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
s := &sdkConn{t: t, conn: c, ping: make(chan string, 8)}
|
||||||
|
// The SDK answers the server's "saQut" ping with a matching pong (gorilla's
|
||||||
|
// default does exactly this); we also record the payload so the heartbeat test
|
||||||
|
// can assert on it.
|
||||||
|
c.SetPingHandler(func(appData string) error {
|
||||||
|
select {
|
||||||
|
case s.ping <- appData:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return c.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(time.Second))
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { c.Close() })
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendRequest mirrors WSTSProtocol.SendRequest: [pack, id, "R"].
|
||||||
|
func (s *sdkConn) sendRequest(pack map[string]any, id int) {
|
||||||
|
s.write([]any{pack, id, "R"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendOnly mirrors WSTSProtocol.SendOnly: [pack, "R"] (string in the id slot).
|
||||||
|
func (s *sdkConn) sendOnly(pack map[string]any) {
|
||||||
|
s.write([]any{pack, "R"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sdkConn) write(frame any) {
|
||||||
|
s.t.Helper()
|
||||||
|
if err := s.conn.WriteJSON(frame); err != nil {
|
||||||
|
s.t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readReply reads frames until it finds a frame correlated to request id. This
|
||||||
|
// matches the SDK's PackAnalyze, which resolves a pending request whenever the id
|
||||||
|
// slot is the matching number — whether the frame is a 3-element reply
|
||||||
|
// [payload, id, "E"] (a direct handler answer) or a 2-element frame
|
||||||
|
// [payload, id] (an out-of-band answer such as response/to). Server signals
|
||||||
|
// (string id slot) seen along the way are skipped.
|
||||||
|
func (s *sdkConn) readReply(id int) (any, string) {
|
||||||
|
s.t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
_ = s.conn.SetReadDeadline(deadline)
|
||||||
|
var arr []any
|
||||||
|
if err := s.conn.ReadJSON(&arr); err != nil {
|
||||||
|
s.t.Fatalf("read reply for id %d: %v", id, err)
|
||||||
|
}
|
||||||
|
if len(arr) >= 2 {
|
||||||
|
if n, ok := arr[1].(float64); ok && int(n) == id {
|
||||||
|
flag := ""
|
||||||
|
if len(arr) >= 3 {
|
||||||
|
flag, _ = arr[2].(string)
|
||||||
|
}
|
||||||
|
return arr[0], flag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readSignal reads frames until it finds a [payload, name] signal (string id
|
||||||
|
// slot, no third element) matching name.
|
||||||
|
func (s *sdkConn) readSignal(name string) map[string]any {
|
||||||
|
s.t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
_ = s.conn.SetReadDeadline(deadline)
|
||||||
|
var arr []any
|
||||||
|
if err := s.conn.ReadJSON(&arr); err != nil {
|
||||||
|
s.t.Fatalf("read signal %q: %v", name, err)
|
||||||
|
}
|
||||||
|
if len(arr) == 2 {
|
||||||
|
if s2, ok := arr[1].(string); ok && s2 == name {
|
||||||
|
payload, _ := arr[0].(map[string]any)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContractMySocketID(t *testing.T) {
|
||||||
|
url := testEngine(t)
|
||||||
|
c := dial(t, url)
|
||||||
|
|
||||||
|
// The SDK's Peer.metadata() does exactly this to learn its own id.
|
||||||
|
c.sendRequest(map[string]any{"type": "my/socketid"}, 1)
|
||||||
|
payload, flag := c.readReply(1)
|
||||||
|
if flag != "E" {
|
||||||
|
t.Fatalf("reply flag = %q, want E", flag)
|
||||||
|
}
|
||||||
|
if _, ok := payload.(string); !ok {
|
||||||
|
t.Fatalf("my/socketid payload = %T, want string id", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContractCreateAndJoinRoom(t *testing.T) {
|
||||||
|
url := testEngine(t)
|
||||||
|
a := dial(t, url)
|
||||||
|
b := dial(t, url)
|
||||||
|
|
||||||
|
// Room.createRoom payload shape.
|
||||||
|
a.sendRequest(map[string]any{
|
||||||
|
"type": "create-room",
|
||||||
|
"accessType": "public",
|
||||||
|
"joinType": "free",
|
||||||
|
"notifyActionInvite": false,
|
||||||
|
"notifyActionJoined": true,
|
||||||
|
"notifyActionEjected": true,
|
||||||
|
"description": "demo",
|
||||||
|
"name": "lobby",
|
||||||
|
}, 10)
|
||||||
|
res, _ := a.readReply(10)
|
||||||
|
resm, _ := res.(map[string]any)
|
||||||
|
if resm["status"] != "success" {
|
||||||
|
t.Fatalf("create-room = %v", res)
|
||||||
|
}
|
||||||
|
room, _ := resm["room"].(map[string]any)
|
||||||
|
if room["id"] == nil {
|
||||||
|
t.Fatalf("create-room reply missing room.id: %v", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Room.join payload shape.
|
||||||
|
b.sendRequest(map[string]any{"type": "joinroom", "name": "lobby", "autoFetchInfo": false}, 11)
|
||||||
|
jres, _ := b.readReply(11)
|
||||||
|
if m, _ := jres.(map[string]any); m["status"] != "success" {
|
||||||
|
t.Fatalf("joinroom = %v", jres)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContractPairingSignals(t *testing.T) {
|
||||||
|
url := testEngine(t)
|
||||||
|
a := dial(t, url)
|
||||||
|
b := dial(t, url)
|
||||||
|
|
||||||
|
aID := mustID(t, a)
|
||||||
|
bID := mustID(t, b)
|
||||||
|
|
||||||
|
// a: requestPair(b)
|
||||||
|
a.sendRequest(map[string]any{"type": "request/pair", "to": bID}, 20)
|
||||||
|
if m := a.replyMap(20); m["status"] != "success" {
|
||||||
|
t.Fatalf("request/pair = %v", m)
|
||||||
|
}
|
||||||
|
// b receives a request/pair signal carrying a's id.
|
||||||
|
if sig := b.readSignal("request/pair"); sig["from"] != aID {
|
||||||
|
t.Fatalf("request/pair signal from = %v, want %s", sig["from"], aID)
|
||||||
|
}
|
||||||
|
// b: acceptPair(a)
|
||||||
|
b.sendRequest(map[string]any{"type": "accept/pair", "to": aID}, 21)
|
||||||
|
if m := b.replyMap(21); m["status"] != "success" {
|
||||||
|
t.Fatalf("accept/pair = %v", m)
|
||||||
|
}
|
||||||
|
// a receives accepted/pair.
|
||||||
|
if sig := a.readSignal("accepted/pair"); sig["from"] != bID {
|
||||||
|
t.Fatalf("accepted/pair from = %v, want %s", sig["from"], bID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContractPackToRelay(t *testing.T) {
|
||||||
|
url := testEngine(t)
|
||||||
|
a := dial(t, url)
|
||||||
|
b := dial(t, url)
|
||||||
|
_ = mustID(t, a)
|
||||||
|
bID := mustID(t, b)
|
||||||
|
|
||||||
|
// Peer.send WOM path: SendOnly pack/to (no reply expected).
|
||||||
|
a.sendOnly(map[string]any{"type": "pack/to", "to": bID, "pack": map[string]any{"text": "hello"}})
|
||||||
|
|
||||||
|
sig := b.readSignal("pack")
|
||||||
|
pack, _ := sig["pack"].(map[string]any)
|
||||||
|
if pack["text"] != "hello" {
|
||||||
|
t.Fatalf("relayed pack = %v", sig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContractRequestResponseOverWire(t *testing.T) {
|
||||||
|
url := testEngine(t)
|
||||||
|
a := dial(t, url)
|
||||||
|
b := dial(t, url)
|
||||||
|
aID := mustID(t, a)
|
||||||
|
bID := mustID(t, b)
|
||||||
|
|
||||||
|
// a: mwse.request(b, {q:"ping"}) -> request/to with a numeric id, answered
|
||||||
|
// out-of-band by b's response/to. The engine must NOT pre-answer id 30.
|
||||||
|
a.sendRequest(map[string]any{"type": "request/to", "to": bID, "pack": map[string]any{"q": "ping"}}, 30)
|
||||||
|
|
||||||
|
req := b.readSignal("request")
|
||||||
|
if req["from"] != aID {
|
||||||
|
t.Fatalf("request signal from = %v, want %s", req["from"], aID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// b: mwse.response(a, 30, {a:"pong"}) -> response/to (SendOnly) reusing id 30.
|
||||||
|
b.sendOnly(map[string]any{"type": "response/to", "to": aID, "id": 30, "pack": map[string]any{"a": "pong"}})
|
||||||
|
|
||||||
|
// a's pending request id 30 resolves with {from:b, pack:{a:"pong"}}.
|
||||||
|
ans := a.replyMap(30)
|
||||||
|
if ans["from"] != bID {
|
||||||
|
t.Fatalf("answer from = %v, want %s", ans["from"], bID)
|
||||||
|
}
|
||||||
|
if p, _ := ans["pack"].(map[string]any); p["a"] != "pong" {
|
||||||
|
t.Fatalf("answer pack = %v", ans)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContractHeartbeat(t *testing.T) {
|
||||||
|
url := testEngine(t)
|
||||||
|
c := dial(t, url)
|
||||||
|
// Drive the read pump so control frames (pings) are processed.
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
if _, _, err := c.conn.ReadMessage(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case payload := <-c.ping:
|
||||||
|
if payload != "saQut" {
|
||||||
|
t.Fatalf("server ping payload = %q, want saQut", payload)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("no heartbeat ping received within 2s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- small helpers -------------------------------------------------------
|
||||||
|
|
||||||
|
// replyMap reads the reply for id and asserts it is an object.
|
||||||
|
func (s *sdkConn) replyMap(id int) map[string]any {
|
||||||
|
s.t.Helper()
|
||||||
|
payload, _ := s.readReply(id)
|
||||||
|
m, ok := payload.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
s.t.Fatalf("reply for id %d = %T, want object", id, payload)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustID learns the connection's own id the way the SDK's Peer.metadata() does.
|
||||||
|
func mustID(t *testing.T, c *sdkConn) string {
|
||||||
|
t.Helper()
|
||||||
|
id := 9000 + int(time.Now().UnixNano()%1000)
|
||||||
|
c.sendRequest(map[string]any{"type": "my/socketid"}, id)
|
||||||
|
payload, _ := c.readReply(id)
|
||||||
|
s, ok := payload.(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("my/socketid payload = %T", payload)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
// Package httpserver assembles the HTTP surface of the engine: the WebSocket
|
||||||
|
// upgrade endpoint, the ES-module SDK routes, the public asset directory, and
|
||||||
|
// the /api control plane.
|
||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"git.saqut.com/saqut/mwse/internal/bridge"
|
||||||
|
"git.saqut.com/saqut/mwse/internal/config"
|
||||||
|
"git.saqut.com/saqut/mwse/internal/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServerOptions holds optional wiring for httpserver.New.
|
||||||
|
type ServerOptions struct {
|
||||||
|
// BridgeInbox, when non-nil, enables POST /api/bridge/inbox so an application
|
||||||
|
// server can drain client messages routed via bridge/send (#46).
|
||||||
|
BridgeInbox *bridge.Inbox
|
||||||
|
// Approver, when non-nil, gates each incoming WebSocket connection by asking
|
||||||
|
// the approver before the HTTP upgrade (#46).
|
||||||
|
Approver ws.Approver
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds the *http.Server. WebSocket upgrades are detected on any path and
|
||||||
|
// routed to the engine; all other requests go through the static/API mux.
|
||||||
|
func New(hub *ws.Hub, cfg config.Config, srvOpts ...ServerOptions) *http.Server {
|
||||||
|
var so ServerOptions
|
||||||
|
if len(srvOpts) > 0 {
|
||||||
|
so = srvOpts[0]
|
||||||
|
}
|
||||||
|
wsServer := ws.NewServer(hub, ws.Options{
|
||||||
|
OutboundBuffer: cfg.Conn.OutboundBuffer,
|
||||||
|
MaxMessageSize: cfg.Conn.MaxMessageSize,
|
||||||
|
ReadBufferSize: cfg.Conn.ReadBufferSize,
|
||||||
|
WriteBufferSize: cfg.Conn.WriteBufferSize,
|
||||||
|
PingInterval: cfg.Conn.PingInterval,
|
||||||
|
PongWait: cfg.Conn.PongWait,
|
||||||
|
WriteWait: cfg.Conn.WriteWait,
|
||||||
|
Approver: so.Approver,
|
||||||
|
})
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
registerAPI(mux, hub, so.BridgeInbox)
|
||||||
|
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:
|
||||||
|
//
|
||||||
|
// - /sdk.js -> 301 /sdk/index.js (import.meta.url resolves correctly)
|
||||||
|
// - /sdk/ -> ES-module SDK files served from cfg.SDKDir
|
||||||
|
// - /studio -> Studio product HTML (built-in UI)
|
||||||
|
// - /studio/ -> Studio JS/CSS assets under public/studio/
|
||||||
|
// - / -> /sdk/index.js redirect (bare URL returns the SDK entry)
|
||||||
|
// - /<file> -> matching file under cfg.PublicDir
|
||||||
|
// - anything -> public/status.xml fallback
|
||||||
|
func registerStatic(mux *http.ServeMux, cfg config.Config) {
|
||||||
|
statusDoc := filepath.Join(cfg.PublicDir, "status.xml")
|
||||||
|
studioDir := filepath.Join(cfg.PublicDir, "studio")
|
||||||
|
studioHTML := filepath.Join(studioDir, "index.html")
|
||||||
|
|
||||||
|
// /sdk.js → /sdk/index.js: keeps import.meta.url = /sdk/index.js so that
|
||||||
|
// ./EventTarget.js etc. resolve to /sdk/EventTarget.js (same origin).
|
||||||
|
mux.HandleFunc("/sdk.js", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/sdk/index.js", http.StatusMovedPermanently)
|
||||||
|
})
|
||||||
|
mux.Handle("/sdk/", http.StripPrefix("/sdk/", http.FileServer(http.Dir(cfg.SDKDir))))
|
||||||
|
|
||||||
|
// /studio → Studio product (built-in management UI).
|
||||||
|
// /studio/ → JS/CSS assets (ColumnView.js, Studio.js, style.css, …).
|
||||||
|
mux.HandleFunc("/studio", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.ServeFile(w, r, studioHTML)
|
||||||
|
})
|
||||||
|
mux.Handle("/studio/", http.StripPrefix("/studio/", http.FileServer(http.Dir(studioDir))))
|
||||||
|
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/" {
|
||||||
|
http.Redirect(w, r, "/sdk/index.js", http.StatusFound)
|
||||||
|
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,218 @@
|
||||||
|
// Package notify implements store-and-forward notifications (#43) and their
|
||||||
|
// reply-bearing "suit" variant (#44). A notification addressed to a client that
|
||||||
|
// is offline is held until the client connects; one addressed to an online client
|
||||||
|
// is delivered immediately. Every notification carries a trace id so its delivery
|
||||||
|
// (and, for suit notifications, its reply) can be queried later.
|
||||||
|
//
|
||||||
|
// The store is bounded in two independent ways so it can never grow without
|
||||||
|
// limit, even if a target never reconnects: an expiry per notification and a hard
|
||||||
|
// per-target cap (oldest dropped first). A janitor periodically purges expired
|
||||||
|
// entries; tests drive expiry deterministically through an injectable clock.
|
||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notification is one store-and-forward message addressed to a client id.
|
||||||
|
type Notification struct {
|
||||||
|
Trace string // unique id used to query delivery/reply status
|
||||||
|
From string // sender: a client id, or "server" for server-originated
|
||||||
|
To string // target client id
|
||||||
|
Pack any // opaque payload delivered to the target
|
||||||
|
Suit bool // true => a reply is expected (#44)
|
||||||
|
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt time.Time // zero == never expires
|
||||||
|
Delivered bool
|
||||||
|
DeliveredAt time.Time
|
||||||
|
|
||||||
|
Replied bool
|
||||||
|
Reply any
|
||||||
|
RepliedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Notification) expired(now time.Time) bool {
|
||||||
|
return !n.ExpiresAt.IsZero() && now.After(n.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store is the in-memory notification registry. All methods are safe for
|
||||||
|
// concurrent use.
|
||||||
|
type Store struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
pending map[string][]*Notification // target id -> queued, undelivered
|
||||||
|
byTrace map[string]*Notification // trace -> notification (status + reply)
|
||||||
|
|
||||||
|
now func() time.Time
|
||||||
|
defaultTTL time.Duration
|
||||||
|
maxPerTarget int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStore returns an empty store with sensible defaults (24h TTL, 1024 queued
|
||||||
|
// notifications per offline target).
|
||||||
|
func NewStore() *Store {
|
||||||
|
return &Store{
|
||||||
|
pending: make(map[string][]*Notification),
|
||||||
|
byTrace: make(map[string]*Notification),
|
||||||
|
now: time.Now,
|
||||||
|
defaultTTL: 24 * time.Hour,
|
||||||
|
maxPerTarget: 1024,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetClock replaces the time source; intended for deterministic tests.
|
||||||
|
func (s *Store) SetClock(now func() time.Time) { s.now = now }
|
||||||
|
|
||||||
|
// Put records a notification for `to`. A zero ttl uses the store default. The
|
||||||
|
// returned Notification is a snapshot; the trace id is filled in.
|
||||||
|
func (s *Store) Put(from, to string, pack any, suit bool, ttl time.Duration) Notification {
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = s.defaultTTL
|
||||||
|
}
|
||||||
|
now := s.now()
|
||||||
|
n := &Notification{
|
||||||
|
Trace: newTrace(),
|
||||||
|
From: from,
|
||||||
|
To: to,
|
||||||
|
Pack: pack,
|
||||||
|
Suit: suit,
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: now.Add(ttl),
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
q := s.pending[to]
|
||||||
|
// Enforce the per-target cap by evicting the oldest queued notifications.
|
||||||
|
for len(q) >= s.maxPerTarget && len(q) > 0 {
|
||||||
|
evicted := q[0]
|
||||||
|
q = q[1:]
|
||||||
|
delete(s.byTrace, evicted.Trace)
|
||||||
|
}
|
||||||
|
s.pending[to] = append(q, n)
|
||||||
|
s.byTrace[n.Trace] = n
|
||||||
|
return *n
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain returns every non-expired pending notification for `to`, marking each
|
||||||
|
// delivered and removing it from the pending queue. Delivered notifications stay
|
||||||
|
// in the trace index (until they expire) so their status can still be queried.
|
||||||
|
func (s *Store) Drain(to string) []Notification {
|
||||||
|
now := s.now()
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
q := s.pending[to]
|
||||||
|
if len(q) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
delete(s.pending, to)
|
||||||
|
|
||||||
|
out := make([]Notification, 0, len(q))
|
||||||
|
for _, n := range q {
|
||||||
|
if n.expired(now) {
|
||||||
|
delete(s.byTrace, n.Trace)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n.Delivered = true
|
||||||
|
n.DeliveredAt = now
|
||||||
|
out = append(out, *n)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status returns a snapshot of the notification with the given trace.
|
||||||
|
func (s *Store) Status(trace string) (Notification, bool) {
|
||||||
|
now := s.now()
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
n, ok := s.byTrace[trace]
|
||||||
|
if !ok || n.expired(now) {
|
||||||
|
return Notification{}, false
|
||||||
|
}
|
||||||
|
return *n, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reply records a client's reply to a suit notification and returns the updated
|
||||||
|
// snapshot. It fails if the trace is unknown, expired, or not a suit.
|
||||||
|
func (s *Store) Reply(trace string, reply any) (Notification, bool) {
|
||||||
|
now := s.now()
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
n, ok := s.byTrace[trace]
|
||||||
|
if !ok || n.expired(now) || !n.Suit {
|
||||||
|
return Notification{}, false
|
||||||
|
}
|
||||||
|
n.Replied = true
|
||||||
|
n.Reply = reply
|
||||||
|
n.RepliedAt = now
|
||||||
|
return *n, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeExpired removes expired notifications from both indexes and returns how
|
||||||
|
// many were removed. A janitor calls this periodically; Drain/Status/Reply also
|
||||||
|
// drop expired entries lazily as they are touched.
|
||||||
|
func (s *Store) PurgeExpired() int {
|
||||||
|
now := s.now()
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
removed := 0
|
||||||
|
for trace, n := range s.byTrace {
|
||||||
|
if n.expired(now) {
|
||||||
|
delete(s.byTrace, trace)
|
||||||
|
removed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for to, q := range s.pending {
|
||||||
|
kept := q[:0]
|
||||||
|
for _, n := range q {
|
||||||
|
if !n.expired(now) {
|
||||||
|
kept = append(kept, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(kept) == 0 {
|
||||||
|
delete(s.pending, to)
|
||||||
|
} else {
|
||||||
|
s.pending[to] = kept
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingCount reports how many notifications are queued for a target (test aid).
|
||||||
|
func (s *Store) PendingCount(to string) int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return len(s.pending[to])
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartJanitor runs PurgeExpired every interval until the returned stop function
|
||||||
|
// is called. main() owns the lifecycle; the stop function makes it leak-free.
|
||||||
|
func (s *Store) StartJanitor(interval time.Duration) (stop func()) {
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(interval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-t.C:
|
||||||
|
s.PurgeExpired()
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
var once sync.Once
|
||||||
|
return func() { once.Do(func() { close(done) }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTrace() string {
|
||||||
|
var b [16]byte
|
||||||
|
_, _ = rand.Read(b[:])
|
||||||
|
return hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPutAndDrainDelivers(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
n := s.Put("alice", "bob", map[string]any{"text": "hi"}, false, 0)
|
||||||
|
if n.Trace == "" {
|
||||||
|
t.Fatal("Put should assign a trace id")
|
||||||
|
}
|
||||||
|
if s.PendingCount("bob") != 1 {
|
||||||
|
t.Fatalf("pending for bob = %d, want 1", s.PendingCount("bob"))
|
||||||
|
}
|
||||||
|
|
||||||
|
got := s.Drain("bob")
|
||||||
|
if len(got) != 1 || got[0].Trace != n.Trace {
|
||||||
|
t.Fatalf("Drain = %+v, want the queued notification", got)
|
||||||
|
}
|
||||||
|
if !got[0].Delivered {
|
||||||
|
t.Fatal("drained notification should be marked delivered")
|
||||||
|
}
|
||||||
|
if s.PendingCount("bob") != 0 {
|
||||||
|
t.Fatal("pending should be empty after drain")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status still works after delivery.
|
||||||
|
st, ok := s.Status(n.Trace)
|
||||||
|
if !ok || !st.Delivered {
|
||||||
|
t.Fatalf("Status after drain = %+v, ok=%v", st, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiryDropsNotification(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
now := time.Unix(1000, 0)
|
||||||
|
s.SetClock(func() time.Time { return now })
|
||||||
|
|
||||||
|
s.Put("srv", "bob", "payload", false, 5*time.Second)
|
||||||
|
|
||||||
|
// Advance past expiry; the queued message must not be delivered.
|
||||||
|
now = now.Add(10 * time.Second)
|
||||||
|
if got := s.Drain("bob"); len(got) != 0 {
|
||||||
|
t.Fatalf("expired notification was delivered: %+v", got)
|
||||||
|
}
|
||||||
|
if removed := s.PurgeExpired(); removed != 0 {
|
||||||
|
// Drain already dropped it from byTrace; nothing left to purge.
|
||||||
|
t.Fatalf("PurgeExpired removed %d, want 0 (already dropped on drain)", removed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurgeExpiredReclaimsUndeliveredTraces(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
now := time.Unix(0, 0)
|
||||||
|
s.SetClock(func() time.Time { return now })
|
||||||
|
|
||||||
|
// A target that never connects: its messages must still be reclaimed.
|
||||||
|
s.Put("srv", "ghost", "a", false, time.Second)
|
||||||
|
s.Put("srv", "ghost", "b", false, time.Second)
|
||||||
|
|
||||||
|
now = now.Add(2 * time.Second)
|
||||||
|
removed := s.PurgeExpired()
|
||||||
|
if removed != 2 {
|
||||||
|
t.Fatalf("PurgeExpired removed %d, want 2", removed)
|
||||||
|
}
|
||||||
|
if s.PendingCount("ghost") != 0 {
|
||||||
|
t.Fatal("expired pending queue should be gone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPerTargetCapEvictsOldest(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
s.maxPerTarget = 3
|
||||||
|
|
||||||
|
var traces []string
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
traces = append(traces, s.Put("srv", "bob", i, false, 0).Trace)
|
||||||
|
}
|
||||||
|
if s.PendingCount("bob") != 3 {
|
||||||
|
t.Fatalf("pending = %d, want capped at 3", s.PendingCount("bob"))
|
||||||
|
}
|
||||||
|
// The two oldest must have been evicted from the trace index too.
|
||||||
|
if _, ok := s.Status(traces[0]); ok {
|
||||||
|
t.Fatal("oldest notification should have been evicted")
|
||||||
|
}
|
||||||
|
if _, ok := s.Status(traces[4]); !ok {
|
||||||
|
t.Fatal("newest notification should be retained")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSuitReply(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
n := s.Put("srv", "bob", "question", true, 0)
|
||||||
|
|
||||||
|
// A non-suit reply target must fail.
|
||||||
|
plain := s.Put("srv", "bob", "fyi", false, 0)
|
||||||
|
if _, ok := s.Reply(plain.Trace, "nope"); ok {
|
||||||
|
t.Fatal("replying to a non-suit notification should fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, ok := s.Reply(n.Trace, map[string]any{"answer": 42})
|
||||||
|
if !ok || !updated.Replied {
|
||||||
|
t.Fatalf("Reply = %+v ok=%v", updated, ok)
|
||||||
|
}
|
||||||
|
st, _ := s.Status(n.Trace)
|
||||||
|
if !st.Replied {
|
||||||
|
t.Fatal("status should reflect the reply")
|
||||||
|
}
|
||||||
|
if m, _ := st.Reply.(map[string]any); m["answer"] != 42 {
|
||||||
|
t.Fatalf("stored reply = %v", st.Reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownTraceStatus(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
if _, ok := s.Status("does-not-exist"); ok {
|
||||||
|
t.Fatal("unknown trace should not resolve")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,15 @@
|
||||||
|
package protocol
|
||||||
|
|
||||||
|
// WSTSVersion is the wire protocol version. The SDK checks this against its own
|
||||||
|
// version constant (sdk/version.js) on every connection: if they differ the SDK
|
||||||
|
// refuses to proceed. Bump both together when making a breaking wire change.
|
||||||
|
//
|
||||||
|
// Current versioning scheme:
|
||||||
|
// "1.x.x" JSON text frames (codec id 0) — v0.1.0 → current
|
||||||
|
// "2.x.x" Binary frames (codec id 1) — planned, issue #42
|
||||||
|
const WSTSVersion = "1.0.0"
|
||||||
|
|
||||||
|
// WSTSCodecJSON is the codec identifier for the current JSON text framing.
|
||||||
|
// The server lists supported codecs in the wsts/hello signal so the client can
|
||||||
|
// negotiate the best mode it understands.
|
||||||
|
const WSTSCodecJSON = 0
|
||||||
|
|
@ -0,0 +1,222 @@
|
||||||
|
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 remove EVERY pairing edge
|
||||||
|
// that references this client (both directions) so no stale id is ever left on
|
||||||
|
// a surviving peer. Using the outgoing+incoming indexes keeps this O(degree),
|
||||||
|
// not O(all clients), and prevents unbounded growth under churn.
|
||||||
|
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)
|
||||||
|
|
||||||
|
cleaned := make(map[string]bool)
|
||||||
|
for _, peerID := range append(c.Pairs(), c.PairedBy()...) {
|
||||||
|
if cleaned[peerID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cleaned[peerID] = true
|
||||||
|
if peer, ok := hub.Client(peerID); ok {
|
||||||
|
peer.ForgetPeer(c.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(target)
|
||||||
|
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(requester)
|
||||||
|
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(other)
|
||||||
|
other.RemovePair(c)
|
||||||
|
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,21 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.saqut.com/saqut/mwse/internal/bridge"
|
||||||
|
"git.saqut.com/saqut/mwse/internal/protocol"
|
||||||
|
"git.saqut.com/saqut/mwse/internal/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerBridge wires the bridge/send handler that routes client messages into
|
||||||
|
// the application server's inbox (#46). The inbox is then drained by the
|
||||||
|
// application server via POST /api/bridge/inbox.
|
||||||
|
func registerBridge(hub *ws.Hub, inbox *bridge.Inbox) {
|
||||||
|
hub.Register("bridge/send", func(c *ws.Client, m protocol.Message) any {
|
||||||
|
pack := m.Get("pack")
|
||||||
|
if pack == nil {
|
||||||
|
return fail("PACK_REQUIRED")
|
||||||
|
}
|
||||||
|
inbox.Push(c.ID, pack)
|
||||||
|
return success()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.saqut.com/saqut/mwse/internal/bridge"
|
||||||
|
"git.saqut.com/saqut/mwse/internal/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBridgeSendQueuesInInbox verifies that a bridge/send message from a client
|
||||||
|
// is queued into the inbox and that a missing pack is rejected (#46).
|
||||||
|
func TestBridgeSendQueuesInInbox(t *testing.T) {
|
||||||
|
hub := ws.NewHub()
|
||||||
|
inbox := bridge.NewInbox(0)
|
||||||
|
Register(hub, WithBridgeInbox(inbox))
|
||||||
|
|
||||||
|
a, _ := connect(hub, "alice")
|
||||||
|
|
||||||
|
// A well-formed bridge/send should be queued.
|
||||||
|
res := asMap(t, hub.Handle(a, msg("bridge/send", "pack", map[string]any{"hello": "world"})))
|
||||||
|
if res["status"] != "success" {
|
||||||
|
t.Fatalf("bridge/send = %v", res)
|
||||||
|
}
|
||||||
|
if inbox.Len() != 1 {
|
||||||
|
t.Fatalf("inbox len = %d, want 1", inbox.Len())
|
||||||
|
}
|
||||||
|
msgs := inbox.Drain()
|
||||||
|
if msgs[0].From != "alice" {
|
||||||
|
t.Fatalf("inbox.From = %q, want alice", msgs[0].From)
|
||||||
|
}
|
||||||
|
if pack, ok := msgs[0].Pack.(map[string]any); !ok || pack["hello"] != "world" {
|
||||||
|
t.Fatalf("inbox.Pack = %v, want hello:world", msgs[0].Pack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBridgeSendRejectsEmptyPack(t *testing.T) {
|
||||||
|
hub := ws.NewHub()
|
||||||
|
inbox := bridge.NewInbox(0)
|
||||||
|
Register(hub, WithBridgeInbox(inbox))
|
||||||
|
a, _ := connect(hub, "bob")
|
||||||
|
|
||||||
|
res := asMap(t, hub.Handle(a, msg("bridge/send")))
|
||||||
|
if res["message"] != "PACK_REQUIRED" {
|
||||||
|
t.Fatalf("expected PACK_REQUIRED, got %v", res)
|
||||||
|
}
|
||||||
|
if inbox.Len() != 0 {
|
||||||
|
t.Fatal("inbox should be empty after rejected send")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBridgeSendNotRegisteredWithoutOption(t *testing.T) {
|
||||||
|
// Without WithBridgeInbox, bridge/send should not be registered.
|
||||||
|
hub := ws.NewHub()
|
||||||
|
Register(hub) // no bridge option
|
||||||
|
a, _ := connect(hub, "charlie")
|
||||||
|
|
||||||
|
res := asMap(t, hub.Handle(a, msg("bridge/send", "pack", "x")))
|
||||||
|
if res["message"] != "UNKNOWN_TYPE" {
|
||||||
|
t.Fatalf("expected UNKNOWN_TYPE, got %v", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.saqut.com/saqut/mwse/internal/datastore"
|
||||||
|
"git.saqut.com/saqut/mwse/internal/protocol"
|
||||||
|
"git.saqut.com/saqut/mwse/internal/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerDatastore wires the #45 shared data layer: active sync (collections with
|
||||||
|
// CRUD broadcast), passive sync (a fast merge pool), and temp/permanent
|
||||||
|
// datastores. The returned store is handed back so a janitor can reclaim expired
|
||||||
|
// temp stores/pools.
|
||||||
|
//
|
||||||
|
// Wire surface (additive):
|
||||||
|
//
|
||||||
|
// Active sync / collection / datastore:
|
||||||
|
// data/open {id?, kind?, primary?, expires?} -> {status, id, primary, kind, records}
|
||||||
|
// (kind = "temp" | "permanent". The payload field is "kind", NOT "type":
|
||||||
|
// "type" is reserved by WSTS to select the handler.)
|
||||||
|
// data/set {id, record} -> {status, key, seq} + broadcast data/op(set)
|
||||||
|
// data/delete {id, key} -> {status, seq} + broadcast data/op(delete)
|
||||||
|
// data/get {id, key?} -> {status, record|records}
|
||||||
|
//
|
||||||
|
// Passive sync (merge pool):
|
||||||
|
// sync/open {id?, expires?} -> {status, id, items}
|
||||||
|
// sync/push {id, items} -> {status, added} + broadcast sync/add
|
||||||
|
// sync/pull {id} -> {status, items}
|
||||||
|
//
|
||||||
|
// Broadcasts are server signals delivered to every *other* subscriber:
|
||||||
|
//
|
||||||
|
// [ {id, op, record|key, seq}, "data/op" ]
|
||||||
|
// [ {id, items}, "sync/add" ]
|
||||||
|
func registerDatastore(hub *ws.Hub) *datastore.Store {
|
||||||
|
store := datastore.NewStore()
|
||||||
|
|
||||||
|
// Signal a set of subscriber ids, skipping the originator (it already holds the
|
||||||
|
// change and learns the authoritative seq from its own reply).
|
||||||
|
broadcast := func(subs []string, except, name string, payload map[string]any) {
|
||||||
|
for _, id := range subs {
|
||||||
|
if id == except {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cl, ok := hub.Client(id); ok {
|
||||||
|
cl.Signal(name, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A departed client must leave no residual subscription anywhere.
|
||||||
|
hub.OnDisconnect(func(c *ws.Client) { store.UnsubscribeAll(c.ID) })
|
||||||
|
|
||||||
|
// ---- active sync / collection / datastore -------------------------------
|
||||||
|
|
||||||
|
hub.Register("data/open", func(c *ws.Client, m protocol.Message) any {
|
||||||
|
kind := datastore.Temp
|
||||||
|
if m.Str("kind") == string(datastore.Permanent) {
|
||||||
|
kind = datastore.Permanent
|
||||||
|
}
|
||||||
|
ttl := time.Duration(m.Int("expires")) * time.Second
|
||||||
|
ds := store.Open(m.Str("id"), kind, m.Str("primary"), ttl)
|
||||||
|
ds.Subscribe(c.ID)
|
||||||
|
return map[string]any{
|
||||||
|
"status": "success",
|
||||||
|
"id": ds.ID,
|
||||||
|
"primary": ds.Primary,
|
||||||
|
"kind": string(ds.Kind),
|
||||||
|
"records": ds.Snapshot(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
hub.Register("data/set", func(c *ws.Client, m protocol.Message) any {
|
||||||
|
ds, ok := store.Get(m.Str("id"))
|
||||||
|
if !ok {
|
||||||
|
return fail("NOT_FOUND")
|
||||||
|
}
|
||||||
|
rec, ok := ds.Set(toMap(m.Get("record")), store.Now())
|
||||||
|
if !ok {
|
||||||
|
return fail("PRIMARY_KEY_REQUIRED")
|
||||||
|
}
|
||||||
|
broadcast(ds.Subscribers(), c.ID, "data/op", map[string]any{
|
||||||
|
"id": ds.ID,
|
||||||
|
"op": "set",
|
||||||
|
"record": rec,
|
||||||
|
"seq": rec.Seq,
|
||||||
|
})
|
||||||
|
return map[string]any{"status": "success", "key": rec.Key, "seq": rec.Seq}
|
||||||
|
})
|
||||||
|
|
||||||
|
hub.Register("data/delete", func(c *ws.Client, m protocol.Message) any {
|
||||||
|
ds, ok := store.Get(m.Str("id"))
|
||||||
|
if !ok {
|
||||||
|
return fail("NOT_FOUND")
|
||||||
|
}
|
||||||
|
key := m.Str("key")
|
||||||
|
seq, ok := ds.Delete(key)
|
||||||
|
if !ok {
|
||||||
|
return fail("NO_SUCH_KEY")
|
||||||
|
}
|
||||||
|
broadcast(ds.Subscribers(), c.ID, "data/op", map[string]any{
|
||||||
|
"id": ds.ID,
|
||||||
|
"op": "delete",
|
||||||
|
"key": key,
|
||||||
|
"seq": seq,
|
||||||
|
})
|
||||||
|
return map[string]any{"status": "success", "seq": seq}
|
||||||
|
})
|
||||||
|
|
||||||
|
hub.Register("data/get", func(c *ws.Client, m protocol.Message) any {
|
||||||
|
ds, ok := store.Get(m.Str("id"))
|
||||||
|
if !ok {
|
||||||
|
return fail("NOT_FOUND")
|
||||||
|
}
|
||||||
|
if key := m.Str("key"); key != "" {
|
||||||
|
rec, ok := ds.Get(key)
|
||||||
|
if !ok {
|
||||||
|
return fail("NO_SUCH_KEY")
|
||||||
|
}
|
||||||
|
return map[string]any{"status": "success", "record": rec}
|
||||||
|
}
|
||||||
|
return map[string]any{"status": "success", "records": ds.Snapshot()}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- passive sync (merge pool) ------------------------------------------
|
||||||
|
|
||||||
|
hub.Register("sync/open", func(c *ws.Client, m protocol.Message) any {
|
||||||
|
ttl := time.Duration(m.Int("expires")) * time.Second
|
||||||
|
p := store.OpenPool(m.Str("id"), ttl)
|
||||||
|
p.Subscribe(c.ID)
|
||||||
|
return map[string]any{"status": "success", "id": p.ID, "items": p.Items()}
|
||||||
|
})
|
||||||
|
|
||||||
|
hub.Register("sync/push", func(c *ws.Client, m protocol.Message) any {
|
||||||
|
items := toSlice(m.Get("items"))
|
||||||
|
added, subs, ok := store.PushPool(m.Str("id"), items)
|
||||||
|
if !ok {
|
||||||
|
return fail("NOT_FOUND")
|
||||||
|
}
|
||||||
|
if len(added) > 0 {
|
||||||
|
broadcast(subs, c.ID, "sync/add", map[string]any{"id": m.Str("id"), "items": added})
|
||||||
|
}
|
||||||
|
return map[string]any{"status": "success", "added": len(added)}
|
||||||
|
})
|
||||||
|
|
||||||
|
hub.Register("sync/pull", func(c *ws.Client, m protocol.Message) any {
|
||||||
|
p, ok := store.GetPool(m.Str("id"))
|
||||||
|
if !ok {
|
||||||
|
return fail("NOT_FOUND")
|
||||||
|
}
|
||||||
|
return map[string]any{"status": "success", "items": p.Items()}
|
||||||
|
})
|
||||||
|
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
// toSlice coerces a decoded JSON value to a slice, returning nil when it is not
|
||||||
|
// an array.
|
||||||
|
func toSlice(v any) []any {
|
||||||
|
if s, ok := v.([]any); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.saqut.com/saqut/mwse/internal/datastore"
|
||||||
|
"git.saqut.com/saqut/mwse/internal/testutil"
|
||||||
|
"git.saqut.com/saqut/mwse/internal/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
// waitDataOp waits for a data/op broadcast whose "op" field matches (the stream
|
||||||
|
// may contain several — e.g. a set followed by a delete).
|
||||||
|
func waitDataOp(t *testing.T, fc *testutil.FakeConn, op string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
find := func() (map[string]any, bool) {
|
||||||
|
for _, raw := range fc.Writes() {
|
||||||
|
var arr []any
|
||||||
|
if json.Unmarshal(raw, &arr) != nil || len(arr) != 2 || arr[1] != "data/op" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p, ok := arr[0].(map[string]any); ok && p["op"] == op {
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
waitFor(t, func() bool { _, ok := find(); return ok })
|
||||||
|
p, _ := find()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// newHubReg builds a hub with services registered and returns the Registry so a
|
||||||
|
// test can inspect the long-lived stores.
|
||||||
|
func newHubReg() (*ws.Hub, *Registry) {
|
||||||
|
hub := ws.NewHub()
|
||||||
|
reg := Register(hub)
|
||||||
|
return hub, reg
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestActiveSyncBroadcast is the #45 collection/CRUD-broadcast core: a set by one
|
||||||
|
// subscriber is broadcast to every other subscriber with the authoritative seq.
|
||||||
|
func TestActiveSyncBroadcast(t *testing.T) {
|
||||||
|
hub, _ := newHubReg()
|
||||||
|
a, _ := connect(hub, "alice")
|
||||||
|
b, fb := connect(hub, "bob")
|
||||||
|
|
||||||
|
// Both open the same datastore by id.
|
||||||
|
opened := asMap(t, hub.Handle(a, msg("data/open", "id", "shared", "kind", "temp", "primary", "id")))
|
||||||
|
id := opened["id"].(string)
|
||||||
|
hub.Handle(b, msg("data/open", "id", id))
|
||||||
|
|
||||||
|
// alice sets a record.
|
||||||
|
set := asMap(t, hub.Handle(a, msg("data/set", "id", id, "record", map[string]any{"id": "k1", "v": float64(7)})))
|
||||||
|
if set["status"] != "success" {
|
||||||
|
t.Fatalf("data/set = %v", set)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bob receives the data/op broadcast (set).
|
||||||
|
op := waitSignal(t, fb, "data/op")
|
||||||
|
if op["op"] != "set" {
|
||||||
|
t.Fatalf("op = %v, want set", op["op"])
|
||||||
|
}
|
||||||
|
rec := asMap(t, op["record"])
|
||||||
|
if rec["key"] != "k1" {
|
||||||
|
t.Fatalf("broadcast record key = %v, want k1", rec["key"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// bob can read the record back from the authoritative copy. (An in-process
|
||||||
|
// reply carries the Go value; over a real socket it is the same shape as JSON.)
|
||||||
|
got := asMap(t, hub.Handle(b, msg("data/get", "id", id, "key", "k1")))
|
||||||
|
gotRec := got["record"].(datastore.Record)
|
||||||
|
if gotRec.Key != "k1" {
|
||||||
|
t.Fatalf("data/get = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActiveSyncDeleteBroadcast(t *testing.T) {
|
||||||
|
hub, _ := newHubReg()
|
||||||
|
a, _ := connect(hub, "alice")
|
||||||
|
b, fb := connect(hub, "bob")
|
||||||
|
|
||||||
|
hub.Handle(a, msg("data/open", "id", "d", "kind", "temp"))
|
||||||
|
hub.Handle(b, msg("data/open", "id", "d"))
|
||||||
|
hub.Handle(a, msg("data/set", "id", "d", "record", map[string]any{"id": "x"}))
|
||||||
|
|
||||||
|
del := asMap(t, hub.Handle(a, msg("data/delete", "id", "d", "key", "x")))
|
||||||
|
if del["status"] != "success" {
|
||||||
|
t.Fatalf("data/delete = %v", del)
|
||||||
|
}
|
||||||
|
op := waitDataOp(t, fb, "delete")
|
||||||
|
if op["key"] != "x" {
|
||||||
|
t.Fatalf("delete broadcast = %v", op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPassiveSyncConvergence is the #45 merge-pool core: pushes are deduped and
|
||||||
|
// only the delta is broadcast, so subscribers converge.
|
||||||
|
func TestPassiveSyncConvergence(t *testing.T) {
|
||||||
|
hub, _ := newHubReg()
|
||||||
|
a, _ := connect(hub, "alice")
|
||||||
|
b, fb := connect(hub, "bob")
|
||||||
|
|
||||||
|
opened := asMap(t, hub.Handle(a, msg("sync/open", "id", "pool")))
|
||||||
|
id := opened["id"].(string)
|
||||||
|
hub.Handle(b, msg("sync/open", "id", id))
|
||||||
|
|
||||||
|
// alice pushes two items; bob gets them as a delta.
|
||||||
|
res := asMap(t, hub.Handle(a, msg("sync/push", "id", id, "items", []any{"x", "y"})))
|
||||||
|
if res["added"] != 2 {
|
||||||
|
t.Fatalf("push added = %v, want 2", res["added"])
|
||||||
|
}
|
||||||
|
add := waitSignal(t, fb, "sync/add")
|
||||||
|
if items, _ := add["items"].([]any); len(items) != 2 {
|
||||||
|
t.Fatalf("sync/add items = %v, want 2", add["items"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// bob pulls and sees the full converged pool.
|
||||||
|
pull := asMap(t, hub.Handle(b, msg("sync/pull", "id", id)))
|
||||||
|
if items, _ := pull["items"].([]any); len(items) != 2 {
|
||||||
|
t.Fatalf("sync/pull items = %v, want 2", pull["items"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// A push with one new + one existing item broadcasts only the new one.
|
||||||
|
res = asMap(t, hub.Handle(b, msg("sync/push", "id", id, "items", []any{"y", "z"})))
|
||||||
|
if res["added"] != 1 {
|
||||||
|
t.Fatalf("second push added = %v, want 1", res["added"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDataSubscriptionClearedOnDisconnect proves no residual subscription is left
|
||||||
|
// behind (no leak) when a subscriber disconnects.
|
||||||
|
func TestDataSubscriptionClearedOnDisconnect(t *testing.T) {
|
||||||
|
hub, reg := newHubReg()
|
||||||
|
a, _ := connect(hub, "alice")
|
||||||
|
connect(hub, "bob")
|
||||||
|
|
||||||
|
hub.Handle(a, msg("data/open", "id", "d", "kind", "temp"))
|
||||||
|
ds, ok := reg.Data.Get("d")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("datastore should exist")
|
||||||
|
}
|
||||||
|
if len(ds.Subscribers()) != 1 {
|
||||||
|
t.Fatalf("subscribers before disconnect = %v, want [alice]", ds.Subscribers())
|
||||||
|
}
|
||||||
|
|
||||||
|
hub.Disconnect(a)
|
||||||
|
|
||||||
|
if len(ds.Subscribers()) != 0 {
|
||||||
|
t.Fatalf("subscribers after disconnect = %v, want empty", ds.Subscribers())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
c.AddPair(other)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
c.AddPair(other)
|
||||||
|
}
|
||||||
|
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,157 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.saqut.com/saqut/mwse/internal/testutil"
|
||||||
|
"git.saqut.com/saqut/mwse/internal/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
// findReply scans the captured frames for a [payload, id] frame whose id slot is
|
||||||
|
// the given number. This is how a peer's response/to answer reaches the original
|
||||||
|
// requester: the numeric request id sits in the signal slot so the SDK's event
|
||||||
|
// pool resolves the matching pending promise.
|
||||||
|
func findReply(fc *testutil.FakeConn, id float64) (map[string]any, bool) {
|
||||||
|
for _, raw := range fc.Writes() {
|
||||||
|
var arr []any
|
||||||
|
if json.Unmarshal(raw, &arr) != nil || len(arr) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n, ok := arr[1].(float64); ok && n == id {
|
||||||
|
payload, _ := arr[0].(map[string]any)
|
||||||
|
return payload, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequestResponseRoundTrip is the #30 (data tunneling) + #33 (WOM) core: a
|
||||||
|
// request/to is answered out-of-band by the peer's response/to carrying the same
|
||||||
|
// id. The request/to handler itself must return nil so the engine sends no
|
||||||
|
// premature reply that would clobber the pending request.
|
||||||
|
func TestRequestResponseRoundTrip(t *testing.T) {
|
||||||
|
hub := newHub()
|
||||||
|
a, fa := connect(hub, "a")
|
||||||
|
b, fb := connect(hub, "b")
|
||||||
|
|
||||||
|
// a sends a request to b with request id 7.
|
||||||
|
if r := hub.Handle(a, msg("request/to", "to", "b", "pack", map[string]any{"q": "ping"})); r != nil {
|
||||||
|
t.Fatalf("request/to must return nil (answered out-of-band), got %v", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// b receives the request signal, with a's id and the payload, but NO source IP.
|
||||||
|
req := waitSignal(t, fb, "request")
|
||||||
|
if req["from"] != "a" {
|
||||||
|
t.Fatalf("request from = %v, want a", req["from"])
|
||||||
|
}
|
||||||
|
assertNoAddressLeak(t, req)
|
||||||
|
|
||||||
|
// b answers with response/to using the original request id.
|
||||||
|
if r := hub.Handle(b, msg("response/to", "to", "a", "id", float64(7), "pack", map[string]any{"a": "pong"})); r != nil {
|
||||||
|
t.Fatalf("response/to must return nil, got %v", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// a receives [ {from:"b", pack:{a:"pong"}}, 7 ] — resolving request id 7.
|
||||||
|
waitFor(t, func() bool { _, ok := findReply(fa, 7); return ok })
|
||||||
|
ans, _ := findReply(fa, 7)
|
||||||
|
if ans["from"] != "b" {
|
||||||
|
t.Fatalf("answer from = %v, want b", ans["from"])
|
||||||
|
}
|
||||||
|
pack := asMap(t, ans["pack"])
|
||||||
|
if pack["a"] != "pong" {
|
||||||
|
t.Fatalf("answer pack = %v, want {a:pong}", pack)
|
||||||
|
}
|
||||||
|
_ = b
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTunnelDoesNotLeakSourceAddress verifies the virtualization requirement of
|
||||||
|
// #30: a relayed pack carries only the logical sender id and the payload, never
|
||||||
|
// the sender's real IP or device type.
|
||||||
|
func TestTunnelDoesNotLeakSourceAddress(t *testing.T) {
|
||||||
|
hub := newHub()
|
||||||
|
a, _ := connect(hub, "a")
|
||||||
|
_, fb := connect(hub, "b")
|
||||||
|
|
||||||
|
hub.Handle(a, msg("pack/to", "to", "b", "pack", map[string]any{"hi": true}))
|
||||||
|
got := waitSignal(t, fb, "pack")
|
||||||
|
assertNoAddressLeak(t, got)
|
||||||
|
if got["from"] != "a" {
|
||||||
|
t.Fatalf("pack from = %v, want a", got["from"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTunnelLargePayloadIntact verifies #30's large-payload requirement: a big
|
||||||
|
// pack is relayed byte-for-byte (the engine never truncates or mangles it). The
|
||||||
|
// transport's MaxMessageSize default (16 MiB) comfortably covers chunked file
|
||||||
|
// transfer frames.
|
||||||
|
func TestTunnelLargePayloadIntact(t *testing.T) {
|
||||||
|
hub := newHub()
|
||||||
|
a, _ := connect(hub, "a")
|
||||||
|
_, fb := connect(hub, "b")
|
||||||
|
|
||||||
|
big := strings.Repeat("x", 1<<20) // 1 MiB chunk
|
||||||
|
hub.Handle(a, msg("pack/to", "to", "b", "pack", map[string]any{"chunk": big}))
|
||||||
|
|
||||||
|
got := waitSignal(t, fb, "pack")
|
||||||
|
pack := asMap(t, got["pack"])
|
||||||
|
if s, _ := pack["chunk"].(string); len(s) != len(big) {
|
||||||
|
t.Fatalf("relayed chunk length = %d, want %d", len(s), len(big))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebRTCSignalingRelay is the #31 parity test. WebRTC signaling is not a
|
||||||
|
// distinct engine concept: the SDK tunnels offer/answer/ICE as opaque
|
||||||
|
// {type:":rtcpack:", payload:{...}} packs over pack/to. The engine must carry
|
||||||
|
// them through unchanged, in both directions, without inspecting the RTC payload.
|
||||||
|
func TestWebRTCSignalingRelay(t *testing.T) {
|
||||||
|
hub := newHub()
|
||||||
|
a, fa := connect(hub, "a")
|
||||||
|
b, fb := connect(hub, "b")
|
||||||
|
|
||||||
|
signal := func(from *ws.Client, to string, rtc map[string]any) {
|
||||||
|
hub.Handle(from, msg("pack/to", "to", to,
|
||||||
|
"pack", map[string]any{"type": ":rtcpack:", "payload": rtc}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A → B: SDP offer.
|
||||||
|
signal(a, "b", map[string]any{"type": "offer", "value": map[string]any{"sdp": "v=0...offer"}})
|
||||||
|
offerPack := asMap(t, waitSignal(t, fb, "pack")["pack"])
|
||||||
|
if offerPack["type"] != ":rtcpack:" {
|
||||||
|
t.Fatalf("offer relayed as %v, want :rtcpack:", offerPack["type"])
|
||||||
|
}
|
||||||
|
if rtc := asMap(t, offerPack["payload"]); rtc["type"] != "offer" {
|
||||||
|
t.Fatalf("inner signaling type = %v, want offer", rtc["type"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// B → A: SDP answer.
|
||||||
|
signal(b, "a", map[string]any{"type": "answer", "value": map[string]any{"sdp": "v=0...answer"}})
|
||||||
|
answerPack := asMap(t, waitSignal(t, fa, "pack")["pack"])
|
||||||
|
if rtc := asMap(t, answerPack["payload"]); rtc["type"] != "answer" {
|
||||||
|
t.Fatalf("inner signaling type = %v, want answer", rtc["type"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// A → B: ICE candidate — payload carried verbatim.
|
||||||
|
cand := map[string]any{"candidate": "candidate:842163049 1 udp ...", "sdpMLineIndex": float64(0)}
|
||||||
|
signal(a, "b", map[string]any{"type": "icecandidate", "value": cand})
|
||||||
|
waitFor(t, func() bool {
|
||||||
|
for _, raw := range fb.Writes() {
|
||||||
|
if strings.Contains(string(raw), "icecandidate") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertNoAddressLeak fails if a relayed payload exposes anything resembling the
|
||||||
|
// sender's real network identity.
|
||||||
|
func assertNoAddressLeak(t *testing.T, payload map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
for _, k := range []string{"ip", "address", "remoteAddr", "host", "device", "deviceType"} {
|
||||||
|
if _, ok := payload[k]; ok {
|
||||||
|
t.Fatalf("relayed payload leaked %q: %v", k, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,589 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.saqut.com/saqut/mwse/internal/protocol"
|
||||||
|
"git.saqut.com/saqut/mwse/internal/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
// shortCodeAlphabet is the 22-letter set the original used (J, Q, U, W absent).
|
||||||
|
// Three letters give 22³ = 10,648 unique codes.
|
||||||
|
const shortCodeAlphabet = "ABCDEFGHIKLMNOPRSTVXYZ"
|
||||||
|
|
||||||
|
// randomProbes is how many random candidates we try before falling back to a
|
||||||
|
// sequential scan. At low occupancy the first probe almost always succeeds.
|
||||||
|
const randomProbes = 256
|
||||||
|
|
||||||
|
// Announcer receives address allocation events for external monitoring.
|
||||||
|
type Announcer interface {
|
||||||
|
Announce(kind, action, clientID string, value any)
|
||||||
|
}
|
||||||
|
|
||||||
|
type noopAnnouncer struct{}
|
||||||
|
|
||||||
|
func (noopAnnouncer) Announce(string, string, string, any) {}
|
||||||
|
|
||||||
|
// SubNet is a /24 virtual sub-network (10.A.B.0/24).
|
||||||
|
// Clients in a group or room can draw IPs from within a dedicated prefix
|
||||||
|
// so they appear to share the same network segment.
|
||||||
|
//
|
||||||
|
// Thread-safety: protected by its own mutex; IPPressure's outer mutex guards
|
||||||
|
// the subnet registry but not the per-subnet host tables.
|
||||||
|
type SubNet struct {
|
||||||
|
Prefix string // "10.A.B" — the /24 prefix
|
||||||
|
Owner string // room / group id that claimed this subnet
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
hosts map[byte]string // host byte [1..254] → clientID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alloc assigns a random host address within this /24 to clientID.
|
||||||
|
func (sn *SubNet) Alloc(clientID string) (string, bool) {
|
||||||
|
sn.mu.Lock()
|
||||||
|
defer sn.mu.Unlock()
|
||||||
|
|
||||||
|
// Random probe first.
|
||||||
|
for range randomProbes {
|
||||||
|
h := byte(rand.IntN(254)) + 1 // [1..254]
|
||||||
|
if _, busy := sn.hosts[h]; !busy {
|
||||||
|
sn.hosts[h] = clientID
|
||||||
|
return fmt.Sprintf("%s.%d", sn.Prefix, h), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sequential fallback (subnet is more than 98% full).
|
||||||
|
for h := byte(1); h < 255; h++ {
|
||||||
|
if _, busy := sn.hosts[h]; !busy {
|
||||||
|
sn.hosts[h] = clientID
|
||||||
|
return fmt.Sprintf("%s.%d", sn.Prefix, h), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false // /24 exhausted (~254 hosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release frees the host address previously allocated to clientID.
|
||||||
|
func (sn *SubNet) Release(clientID string) {
|
||||||
|
sn.mu.Lock()
|
||||||
|
defer sn.mu.Unlock()
|
||||||
|
for h, id := range sn.hosts {
|
||||||
|
if id == clientID {
|
||||||
|
delete(sn.hosts, h)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whois returns the clientID that holds the given IP within this subnet.
|
||||||
|
func (sn *SubNet) Whois(ip string) (string, bool) {
|
||||||
|
sn.mu.Lock()
|
||||||
|
defer sn.mu.Unlock()
|
||||||
|
var host byte
|
||||||
|
_, err := fmt.Sscanf(ip, sn.Prefix+".%d", &host)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
id, ok := sn.hosts[host]
|
||||||
|
return id, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPPressure allocates virtual addresses to clients.
|
||||||
|
//
|
||||||
|
// Allocation strategy (#41): random probe with sequential fallback.
|
||||||
|
// At typical occupancy (< 1 % of the address space) the first random
|
||||||
|
// candidate is free, so allocation is O(1) in practice.
|
||||||
|
//
|
||||||
|
// Sub-networks (#40): callers can reserve a /24 prefix and hand out IPs
|
||||||
|
// within it. The global flat space and the subnet space are disjoint; a
|
||||||
|
// client that holds a subnet IP is also tracked in busyIP so whois/APIPAddress
|
||||||
|
// queries still work.
|
||||||
|
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 (both flat and subnet)
|
||||||
|
|
||||||
|
// Sub-network registry.
|
||||||
|
subnets map[string]*SubNet // prefix ("10.A.B") → SubNet
|
||||||
|
clientSN map[string]string // clientID → subnet prefix (for disconnect cleanup)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
subnets: make(map[string]*SubNet),
|
||||||
|
clientSN: make(map[string]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Flat IP address (10.x.x.x, random) --------------------------------
|
||||||
|
|
||||||
|
func (p *IPPressure) lockIP(clientID string) string {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
// Random probe across the full 10.0.0.0/8.
|
||||||
|
for range randomProbes {
|
||||||
|
a := byte(rand.IntN(255)) + 1
|
||||||
|
b := byte(rand.IntN(256))
|
||||||
|
c := byte(rand.IntN(255)) + 1
|
||||||
|
ip := fmt.Sprintf("10.%d.%d.%d", a, b, c)
|
||||||
|
if _, busy := p.busyIP[ip]; !busy {
|
||||||
|
p.busyIP[ip] = clientID
|
||||||
|
p.ann.Announce("AP_IPADDRESS", "LOCK", clientID, ip)
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sequential fallback (extremely unlikely with random probes).
|
||||||
|
for a := 1; a <= 255; a++ {
|
||||||
|
for b := 0; b <= 255; b++ {
|
||||||
|
for c := 1; c <= 255; c++ {
|
||||||
|
ip := fmt.Sprintf("10.%d.%d.%d", a, b, c)
|
||||||
|
if _, busy := p.busyIP[ip]; !busy {
|
||||||
|
p.busyIP[ip] = clientID
|
||||||
|
p.ann.Announce("AP_IPADDRESS", "LOCK", clientID, ip)
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "" // address space exhausted (> ~16 million clients)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Number (random in [24, 99999]) -------------------------------------
|
||||||
|
|
||||||
|
func (p *IPPressure) lockNumber(clientID string) int {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
const lo, hi = 24, 99999
|
||||||
|
for range randomProbes {
|
||||||
|
n := rand.IntN(hi-lo+1) + lo
|
||||||
|
if _, busy := p.busyNumber[n]; !busy {
|
||||||
|
p.busyNumber[n] = clientID
|
||||||
|
p.ann.Announce("AP_NUMBER", "LOCK", clientID, n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sequential fallback.
|
||||||
|
for n := lo; n <= hi; n++ {
|
||||||
|
if _, busy := p.busyNumber[n]; !busy {
|
||||||
|
p.busyNumber[n] = clientID
|
||||||
|
p.ann.Announce("AP_NUMBER", "LOCK", clientID, n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Beyond hi: keep counting up without bound.
|
||||||
|
for n := hi + 1; ; 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 (random 3-letter from restricted alphabet) --------------
|
||||||
|
|
||||||
|
func (p *IPPressure) lockCode(clientID string) string {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
n := len(shortCodeAlphabet)
|
||||||
|
for range randomProbes {
|
||||||
|
code := string([]byte{
|
||||||
|
shortCodeAlphabet[rand.IntN(n)],
|
||||||
|
shortCodeAlphabet[rand.IntN(n)],
|
||||||
|
shortCodeAlphabet[rand.IntN(n)],
|
||||||
|
})
|
||||||
|
if _, busy := p.busyCode[code]; !busy {
|
||||||
|
p.busyCode[code] = clientID
|
||||||
|
p.ann.Announce("AP_SHORTCODE", "LOCK", clientID, code)
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sequential fallback.
|
||||||
|
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 "" // all 10,648 codes in use
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Sub-network (#40) -------------------------------------------------
|
||||||
|
|
||||||
|
// allocSubNet reserves a random /24 prefix (10.A.B.0/24) for the caller.
|
||||||
|
// The prefix is held until releaseSubNet is called or the client disconnects.
|
||||||
|
func (p *IPPressure) allocSubNet(ownerID string) (*SubNet, bool) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
for range randomProbes {
|
||||||
|
a := byte(rand.IntN(255)) + 1
|
||||||
|
b := byte(rand.IntN(256))
|
||||||
|
prefix := fmt.Sprintf("10.%d.%d", a, b)
|
||||||
|
if _, busy := p.subnets[prefix]; !busy {
|
||||||
|
sn := &SubNet{Prefix: prefix, Owner: ownerID, hosts: make(map[byte]string)}
|
||||||
|
p.subnets[prefix] = sn
|
||||||
|
p.ann.Announce("AP_SUBNET", "ALLOC", ownerID, prefix)
|
||||||
|
return sn, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sequential fallback.
|
||||||
|
for a := 1; a <= 255; a++ {
|
||||||
|
for b := 0; b <= 255; b++ {
|
||||||
|
prefix := fmt.Sprintf("10.%d.%d", a, b)
|
||||||
|
if _, busy := p.subnets[prefix]; !busy {
|
||||||
|
sn := &SubNet{Prefix: prefix, Owner: ownerID, hosts: make(map[byte]string)}
|
||||||
|
p.subnets[prefix] = sn
|
||||||
|
p.ann.Announce("AP_SUBNET", "ALLOC", ownerID, prefix)
|
||||||
|
return sn, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSubNet looks up the SubNet owned by ownerID.
|
||||||
|
func (p *IPPressure) getSubNet(ownerID string) (*SubNet, bool) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
for _, sn := range p.subnets {
|
||||||
|
if sn.Owner == ownerID {
|
||||||
|
return sn, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseSubNet frees a /24 prefix and removes all host addresses it contained
|
||||||
|
// from the global busyIP table.
|
||||||
|
func (p *IPPressure) releaseSubNet(prefix string) {
|
||||||
|
p.mu.Lock()
|
||||||
|
sn, ok := p.subnets[prefix]
|
||||||
|
if !ok {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(p.subnets, prefix)
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
sn.mu.Lock()
|
||||||
|
defer sn.mu.Unlock()
|
||||||
|
for h, cid := range sn.hosts {
|
||||||
|
ip := fmt.Sprintf("%s.%d", prefix, h)
|
||||||
|
p.mu.Lock()
|
||||||
|
delete(p.busyIP, ip)
|
||||||
|
p.mu.Unlock()
|
||||||
|
p.ann.Announce("AP_SUBNET_IP", "RELEASE", cid, ip)
|
||||||
|
}
|
||||||
|
p.ann.Announce("AP_SUBNET", "RELEASE", sn.Owner, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Service registration ----------------------------------------------
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// --- Flat 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 {
|
||||||
|
old := c.APIP()
|
||||||
|
if old == "" {
|
||||||
|
return map[string]any{"status": "fail"}
|
||||||
|
}
|
||||||
|
ip := p.lockIP(c.ID)
|
||||||
|
if ip == "" {
|
||||||
|
return map[string]any{"status": "fail"}
|
||||||
|
}
|
||||||
|
p.releaseIP(old)
|
||||||
|
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 {
|
||||||
|
old := c.APNumber()
|
||||||
|
if old == 0 {
|
||||||
|
return map[string]any{"status": "fail"}
|
||||||
|
}
|
||||||
|
n := p.lockNumber(c.ID)
|
||||||
|
p.releaseNumber(old)
|
||||||
|
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 {
|
||||||
|
old := c.APShortCode()
|
||||||
|
if old == "" {
|
||||||
|
return map[string]any{"status": "fail"}
|
||||||
|
}
|
||||||
|
code := p.lockCode(c.ID)
|
||||||
|
if code == "" {
|
||||||
|
return map[string]any{"status": "fail"}
|
||||||
|
}
|
||||||
|
p.releaseCode(old)
|
||||||
|
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"}
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Sub-network (#40) ---
|
||||||
|
|
||||||
|
// alloc/APSubNet: claim a /24 prefix for a group/room. The caller becomes
|
||||||
|
// the subnet owner; its members can then call alloc/APSubNetIP.
|
||||||
|
hub.Register("alloc/APSubNet", func(c *ws.Client, m protocol.Message) any {
|
||||||
|
// Idempotent: return existing prefix if the caller already owns one.
|
||||||
|
if sn, ok := p.getSubNet(c.ID); ok {
|
||||||
|
return map[string]any{"status": "success", "prefix": sn.Prefix}
|
||||||
|
}
|
||||||
|
sn, ok := p.allocSubNet(c.ID)
|
||||||
|
if !ok {
|
||||||
|
return map[string]any{"status": "fail"}
|
||||||
|
}
|
||||||
|
// Track on the client so disconnect cleanup works.
|
||||||
|
p.mu.Lock()
|
||||||
|
p.clientSN[c.ID] = sn.Prefix
|
||||||
|
p.mu.Unlock()
|
||||||
|
return map[string]any{"status": "success", "prefix": sn.Prefix}
|
||||||
|
})
|
||||||
|
|
||||||
|
// release/APSubNet: free the prefix. All host IPs are released.
|
||||||
|
hub.Register("release/APSubNet", func(c *ws.Client, m protocol.Message) any {
|
||||||
|
p.mu.Lock()
|
||||||
|
prefix, had := p.clientSN[c.ID]
|
||||||
|
if had {
|
||||||
|
delete(p.clientSN, c.ID)
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
if had {
|
||||||
|
p.releaseSubNet(prefix)
|
||||||
|
}
|
||||||
|
return success()
|
||||||
|
})
|
||||||
|
|
||||||
|
// alloc/APSubNetIP: allocate a host IP within the subnet identified by
|
||||||
|
// the "prefix" field. The caller need not be the owner.
|
||||||
|
hub.Register("alloc/APSubNetIP", func(c *ws.Client, m protocol.Message) any {
|
||||||
|
prefix := m.Str("prefix")
|
||||||
|
p.mu.Lock()
|
||||||
|
sn, ok := p.subnets[prefix]
|
||||||
|
p.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return map[string]any{"status": "fail", "message": "subnet not found"}
|
||||||
|
}
|
||||||
|
ip, ok := sn.Alloc(c.ID)
|
||||||
|
if !ok {
|
||||||
|
return map[string]any{"status": "fail", "message": "subnet exhausted"}
|
||||||
|
}
|
||||||
|
// Register in the global table so flat whois works.
|
||||||
|
p.mu.Lock()
|
||||||
|
p.busyIP[ip] = c.ID
|
||||||
|
p.mu.Unlock()
|
||||||
|
// If the client later disconnects, release via the subnet too.
|
||||||
|
p.mu.Lock()
|
||||||
|
p.clientSN[c.ID] = prefix
|
||||||
|
p.mu.Unlock()
|
||||||
|
c.SetAPIP(ip)
|
||||||
|
return map[string]any{"status": "success", "ip": ip}
|
||||||
|
})
|
||||||
|
|
||||||
|
// release/APSubNetIP: free the current subnet IP of the caller.
|
||||||
|
hub.Register("release/APSubNetIP", func(c *ws.Client, m protocol.Message) any {
|
||||||
|
ip := c.APIP()
|
||||||
|
if ip == "" {
|
||||||
|
return success()
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
prefix := p.clientSN[c.ID]
|
||||||
|
sn, hasSN := p.subnets[prefix]
|
||||||
|
delete(p.clientSN, c.ID)
|
||||||
|
delete(p.busyIP, ip)
|
||||||
|
p.mu.Unlock()
|
||||||
|
if hasSN {
|
||||||
|
sn.Release(c.ID)
|
||||||
|
}
|
||||||
|
c.SetAPIP("")
|
||||||
|
return success()
|
||||||
|
})
|
||||||
|
|
||||||
|
// whois/APSubNetIP: look up a host IP within a given subnet.
|
||||||
|
hub.Register("whois/APSubNetIP", func(c *ws.Client, m protocol.Message) any {
|
||||||
|
prefix := m.Str("prefix")
|
||||||
|
ip := m.Str("whois")
|
||||||
|
p.mu.Lock()
|
||||||
|
sn, ok := p.subnets[prefix]
|
||||||
|
p.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return map[string]any{"status": "fail"}
|
||||||
|
}
|
||||||
|
if id, ok := sn.Whois(ip); ok {
|
||||||
|
return map[string]any{"status": "success", "socket": id}
|
||||||
|
}
|
||||||
|
return map[string]any{"status": "fail"}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Release every address when a client disconnects.
|
||||||
|
hub.OnDisconnect(func(c *ws.Client) {
|
||||||
|
if ip := c.APIP(); ip != "" {
|
||||||
|
p.releaseIP(ip)
|
||||||
|
// Also release from subnet if applicable.
|
||||||
|
p.mu.Lock()
|
||||||
|
if prefix, ok := p.clientSN[c.ID]; ok {
|
||||||
|
if sn, snOK := p.subnets[prefix]; snOK {
|
||||||
|
sn.Release(c.ID)
|
||||||
|
}
|
||||||
|
delete(p.clientSN, c.ID)
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
if c.APNumber() != 0 {
|
||||||
|
p.releaseNumber(c.APNumber())
|
||||||
|
}
|
||||||
|
if code := c.APShortCode(); code != "" {
|
||||||
|
p.releaseCode(code)
|
||||||
|
}
|
||||||
|
// Release owned subnet prefix on disconnect.
|
||||||
|
p.mu.Lock()
|
||||||
|
prefix, hadSN := p.clientSN[c.ID]
|
||||||
|
if hadSN {
|
||||||
|
delete(p.clientSN, c.ID)
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
if hadSN {
|
||||||
|
p.releaseSubNet(prefix)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRandomAllocationIsUnique verifies that 1000 concurrent IP allocations
|
||||||
|
// produce no duplicates (the random probing strategy must be collision-safe).
|
||||||
|
func TestRandomAllocationIsUnique(t *testing.T) {
|
||||||
|
p := NewIPPressure(nil)
|
||||||
|
const n = 1000
|
||||||
|
|
||||||
|
results := make([]string, n)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := range n {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
results[i] = p.lockIP("client-" + string(rune('A'+i%26)) + string(rune('0'+i/26)))
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
seen := make(map[string]int, n)
|
||||||
|
for i, ip := range results {
|
||||||
|
if ip == "" {
|
||||||
|
t.Fatalf("allocation %d returned empty string", i)
|
||||||
|
}
|
||||||
|
if prev, dup := seen[ip]; dup {
|
||||||
|
t.Fatalf("duplicate IP %s at slots %d and %d", ip, prev, i)
|
||||||
|
}
|
||||||
|
seen[ip] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRandomNumberAllocation checks uniqueness for number allocations.
|
||||||
|
func TestRandomNumberAllocation(t *testing.T) {
|
||||||
|
p := NewIPPressure(nil)
|
||||||
|
const n = 500
|
||||||
|
nums := make([]int, n)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := range n {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
nums[i] = p.lockNumber("c" + string(rune('A'+i%26)))
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
seen := make(map[int]bool, n)
|
||||||
|
for _, n := range nums {
|
||||||
|
if seen[n] {
|
||||||
|
t.Fatalf("duplicate number %d", n)
|
||||||
|
}
|
||||||
|
seen[n] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSubNetAllocRelease covers the /24 subnet lifecycle.
|
||||||
|
func TestSubNetAllocRelease(t *testing.T) {
|
||||||
|
hub := newHub()
|
||||||
|
a, _ := connect(hub, "a")
|
||||||
|
b, _ := connect(hub, "b")
|
||||||
|
|
||||||
|
// Allocate a subnet.
|
||||||
|
resp := asMap(t, hub.Handle(a, msg("alloc/APSubNet")))
|
||||||
|
if resp["status"] != "success" {
|
||||||
|
t.Fatalf("alloc/APSubNet = %v", resp)
|
||||||
|
}
|
||||||
|
prefix := resp["prefix"].(string)
|
||||||
|
if !strings.HasPrefix(prefix, "10.") {
|
||||||
|
t.Fatalf("prefix %q should start with '10.'", prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent: second alloc returns the same prefix.
|
||||||
|
resp2 := asMap(t, hub.Handle(a, msg("alloc/APSubNet")))
|
||||||
|
if resp2["prefix"] != prefix {
|
||||||
|
t.Fatalf("second alloc/APSubNet should return same prefix, got %v vs %v", resp2["prefix"], prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// b requests a host IP within a's subnet.
|
||||||
|
ipResp := asMap(t, hub.Handle(b, msg("alloc/APSubNetIP", "prefix", prefix)))
|
||||||
|
if ipResp["status"] != "success" {
|
||||||
|
t.Fatalf("alloc/APSubNetIP = %v", ipResp)
|
||||||
|
}
|
||||||
|
ip := ipResp["ip"].(string)
|
||||||
|
if !strings.HasPrefix(ip, prefix+".") {
|
||||||
|
t.Fatalf("allocated IP %q should be within prefix %q", ip, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// whois query finds b.
|
||||||
|
who := asMap(t, hub.Handle(a, msg("whois/APSubNetIP", "prefix", prefix, "whois", ip)))
|
||||||
|
if who["socket"] != "b" {
|
||||||
|
t.Fatalf("whois/APSubNetIP = %v, want b", who)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release b's host IP.
|
||||||
|
if r := asMap(t, hub.Handle(b, msg("release/APSubNetIP"))); r["status"] != "success" {
|
||||||
|
t.Fatalf("release/APSubNetIP = %v", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release a's subnet.
|
||||||
|
if r := asMap(t, hub.Handle(a, msg("release/APSubNet"))); r["status"] != "success" {
|
||||||
|
t.Fatalf("release/APSubNet = %v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSubNetIPsAreUnique verifies that multiple clients get distinct IPs
|
||||||
|
// within the same subnet.
|
||||||
|
func TestSubNetIPsAreUnique(t *testing.T) {
|
||||||
|
p := NewIPPressure(nil)
|
||||||
|
|
||||||
|
sn, ok := p.allocSubNet("owner")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("allocSubNet failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = 50
|
||||||
|
ips := make([]string, n)
|
||||||
|
for i := range n {
|
||||||
|
ip, ok := sn.Alloc("client-" + string(rune('A'+i)))
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Alloc failed at %d", i)
|
||||||
|
}
|
||||||
|
ips[i] = ip
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]bool, n)
|
||||||
|
for _, ip := range ips {
|
||||||
|
if seen[ip] {
|
||||||
|
t.Fatalf("duplicate subnet IP %s", ip)
|
||||||
|
}
|
||||||
|
seen[ip] = true
|
||||||
|
if !strings.HasPrefix(ip, sn.Prefix+".") {
|
||||||
|
t.Fatalf("IP %q not within prefix %q", ip, sn.Prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.saqut.com/saqut/mwse/internal/notify"
|
||||||
|
"git.saqut.com/saqut/mwse/internal/protocol"
|
||||||
|
"git.saqut.com/saqut/mwse/internal/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerNotify wires the store-and-forward notification service (#43) and its
|
||||||
|
// reply-bearing suit variant (#44). The returned store is handed to the caller so
|
||||||
|
// a janitor can reclaim expired entries.
|
||||||
|
//
|
||||||
|
// Wire surface (all additive — the frozen relay contract is untouched):
|
||||||
|
//
|
||||||
|
// - notify/send {to, pack, expires?, suit?} -> {status, trace}
|
||||||
|
// - notify/status {trace} -> {status, delivered, replied, reply}
|
||||||
|
// - notify/reply {trace, pack} -> {status} (client answering a suit)
|
||||||
|
//
|
||||||
|
// Delivery to the target is the server signal:
|
||||||
|
//
|
||||||
|
// [ {from, pack, trace, suit}, "notify" ]
|
||||||
|
func registerNotify(hub *ws.Hub, trigger NotifyTrigger) *notify.Store {
|
||||||
|
store := notify.NewStore()
|
||||||
|
|
||||||
|
deliver := func(c *ws.Client, n notify.Notification) {
|
||||||
|
c.Signal("notify", map[string]any{
|
||||||
|
"from": n.From,
|
||||||
|
"pack": n.Pack,
|
||||||
|
"trace": n.Trace,
|
||||||
|
"suit": n.Suit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// flush drains and delivers everything queued for a connected client.
|
||||||
|
flush := func(c *ws.Client) {
|
||||||
|
for _, n := range store.Drain(c.ID) {
|
||||||
|
deliver(c, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On connect, deliver anything that arrived while the client was offline.
|
||||||
|
hub.OnConnect(flush)
|
||||||
|
|
||||||
|
hub.Register("notify/send", func(c *ws.Client, m protocol.Message) any {
|
||||||
|
to := m.Str("to")
|
||||||
|
if to == "" {
|
||||||
|
return fail("TO_REQUIRED")
|
||||||
|
}
|
||||||
|
ttl := time.Duration(m.Int("expires")) * time.Second // 0 => store default
|
||||||
|
n := store.Put(c.ID, to, m.Get("pack"), m.Truthy("suit"), ttl)
|
||||||
|
|
||||||
|
// If the target is connected right now, deliver immediately; otherwise it
|
||||||
|
// stays queued until the target's next connect.
|
||||||
|
if target, ok := hub.Client(to); ok {
|
||||||
|
flush(target)
|
||||||
|
}
|
||||||
|
return map[string]any{"status": "success", "trace": n.Trace}
|
||||||
|
})
|
||||||
|
|
||||||
|
hub.Register("notify/status", func(c *ws.Client, m protocol.Message) any {
|
||||||
|
n, ok := store.Status(m.Str("trace"))
|
||||||
|
if !ok {
|
||||||
|
return fail("NOT_FOUND")
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"status": "success",
|
||||||
|
"delivered": n.Delivered,
|
||||||
|
"replied": n.Replied,
|
||||||
|
"reply": n.Reply,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
hub.Register("notify/reply", func(c *ws.Client, m protocol.Message) any {
|
||||||
|
n, ok := store.Reply(m.Str("trace"), m.Get("pack"))
|
||||||
|
if !ok {
|
||||||
|
return fail("NOT_FOUND_OR_NOT_SUIT")
|
||||||
|
}
|
||||||
|
// Push the reply outward to the 3rd-party application server (#44): MWSE
|
||||||
|
// triggers it manually rather than having the server poll notify/status.
|
||||||
|
trigger.NotifyReplied(n)
|
||||||
|
// If the original sender is an online client, signal it the reply too.
|
||||||
|
if origin, ok := hub.Client(n.From); ok {
|
||||||
|
origin.Signal("notify/reply", map[string]any{
|
||||||
|
"trace": n.Trace,
|
||||||
|
"from": c.ID,
|
||||||
|
"pack": n.Reply,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return success()
|
||||||
|
})
|
||||||
|
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.saqut.com/saqut/mwse/internal/notify"
|
||||||
|
"git.saqut.com/saqut/mwse/internal/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recTrigger records suit replies pushed outward (the #44 3rd-party trigger).
|
||||||
|
type recTrigger struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
got []notify.Notification
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recTrigger) NotifyReplied(n notify.Notification) {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.got = append(r.got, n)
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recTrigger) count() int {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return len(r.got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNotifyOfflineThenDeliverOnConnect is the #43 store-and-forward core: a
|
||||||
|
// message left for an offline client is delivered when that client connects.
|
||||||
|
func TestNotifyOfflineThenDeliverOnConnect(t *testing.T) {
|
||||||
|
hub := newHub()
|
||||||
|
a, _ := connect(hub, "alice")
|
||||||
|
|
||||||
|
res := asMap(t, hub.Handle(a, msg("notify/send", "to", "bob", "pack", map[string]any{"text": "hi"})))
|
||||||
|
if res["status"] != "success" {
|
||||||
|
t.Fatalf("notify/send = %v", res)
|
||||||
|
}
|
||||||
|
trace, _ := res["trace"].(string)
|
||||||
|
if trace == "" {
|
||||||
|
t.Fatal("notify/send should return a trace id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// bob was offline; now connects and must receive the queued notification.
|
||||||
|
_, fb := connect(hub, "bob")
|
||||||
|
sig := waitSignal(t, fb, "notify")
|
||||||
|
if sig["trace"] != trace {
|
||||||
|
t.Fatalf("delivered trace = %v, want %s", sig["trace"], trace)
|
||||||
|
}
|
||||||
|
if p, _ := sig["pack"].(map[string]any); p["text"] != "hi" {
|
||||||
|
t.Fatalf("delivered pack = %v", sig["pack"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status reports delivered.
|
||||||
|
st := asMap(t, hub.Handle(a, msg("notify/status", "trace", trace)))
|
||||||
|
if st["delivered"] != true {
|
||||||
|
t.Fatalf("status = %v, want delivered", st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNotifyImmediateWhenOnline delivers without waiting when the target is up.
|
||||||
|
func TestNotifyImmediateWhenOnline(t *testing.T) {
|
||||||
|
hub := newHub()
|
||||||
|
a, _ := connect(hub, "alice")
|
||||||
|
_, fb := connect(hub, "bob")
|
||||||
|
|
||||||
|
hub.Handle(a, msg("notify/send", "to", "bob", "pack", map[string]any{"text": "now"}))
|
||||||
|
sig := waitSignal(t, fb, "notify")
|
||||||
|
if p, _ := sig["pack"].(map[string]any); p["text"] != "now" {
|
||||||
|
t.Fatalf("immediate delivery pack = %v", sig["pack"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNotifySuitReply is the #44 reply path: a suit notification's reply reaches
|
||||||
|
// the 3rd-party trigger and is signalled back to the origin client.
|
||||||
|
func TestNotifySuitReply(t *testing.T) {
|
||||||
|
trig := &recTrigger{}
|
||||||
|
hub := ws.NewHub()
|
||||||
|
Register(hub, WithNotifyTrigger(trig))
|
||||||
|
|
||||||
|
a, fa := connect(hub, "alice")
|
||||||
|
b, fb := connect(hub, "bob")
|
||||||
|
|
||||||
|
res := asMap(t, hub.Handle(a, msg("notify/send", "to", "bob", "suit", true, "pack", map[string]any{"q": "ok?"})))
|
||||||
|
trace, _ := res["trace"].(string)
|
||||||
|
|
||||||
|
sig := waitSignal(t, fb, "notify")
|
||||||
|
if sig["suit"] != true {
|
||||||
|
t.Fatalf("notify suit flag = %v, want true", sig["suit"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// bob replies to the suit.
|
||||||
|
rep := asMap(t, hub.Handle(b, msg("notify/reply", "trace", trace, "pack", map[string]any{"a": "yes"})))
|
||||||
|
if rep["status"] != "success" {
|
||||||
|
t.Fatalf("notify/reply = %v", rep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 3rd-party trigger fired, and the origin (alice) got the reply signal.
|
||||||
|
waitFor(t, func() bool { return trig.count() == 1 })
|
||||||
|
got := waitSignal(t, fa, "notify/reply")
|
||||||
|
if p, _ := got["pack"].(map[string]any); p["a"] != "yes" {
|
||||||
|
t.Fatalf("origin reply signal pack = %v", got["pack"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-suit reply must be rejected.
|
||||||
|
plain := asMap(t, hub.Handle(a, msg("notify/send", "to", "bob", "pack", map[string]any{})))
|
||||||
|
if r := asMap(t, hub.Handle(b, msg("notify/reply", "trace", plain["trace"], "pack", map[string]any{}))); r["status"] != "fail" {
|
||||||
|
t.Fatalf("reply to non-suit = %v, want fail", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,328 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.saqut.com/saqut/mwse/internal/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These tests cover the 1.0.0 engine-parity issues #27 (rooms), #28 (pairing) and
|
||||||
|
// #29 (virtual addressing), plus the leak-hardening done for high-scale operation.
|
||||||
|
// They share the helpers defined in services_test.go.
|
||||||
|
|
||||||
|
// ---- #27 Room system parity ---------------------------------------------
|
||||||
|
|
||||||
|
func TestRoomJoinTypes(t *testing.T) {
|
||||||
|
hub := newHub()
|
||||||
|
owner, _ := connect(hub, "owner")
|
||||||
|
|
||||||
|
create := func(name, joinType, cred string) {
|
||||||
|
fields := []any{
|
||||||
|
"accessType", "public", "joinType", joinType,
|
||||||
|
"notifyActionInvite", false, "notifyActionJoined", true, "notifyActionEjected", true,
|
||||||
|
"description", "d", "name", name,
|
||||||
|
}
|
||||||
|
if cred != "" {
|
||||||
|
fields = append(fields, "credential", cred)
|
||||||
|
}
|
||||||
|
r := asMap(t, hub.Handle(owner, msg("create-room", fields...)))
|
||||||
|
if r["status"] != "success" {
|
||||||
|
t.Fatalf("create-room(%s) = %v", joinType, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
create("free-room", "free", "")
|
||||||
|
create("lock-room", "lock", "")
|
||||||
|
create("pw-room", "password", "secret")
|
||||||
|
|
||||||
|
joiner, _ := connect(hub, "joiner")
|
||||||
|
|
||||||
|
if r := asMap(t, hub.Handle(joiner, msg("joinroom", "name", "free-room"))); r["status"] != "success" {
|
||||||
|
t.Fatalf("free join = %v", r)
|
||||||
|
}
|
||||||
|
if r := asMap(t, hub.Handle(joiner, msg("joinroom", "name", "lock-room"))); r["message"] != "LOCKED-ROOM" {
|
||||||
|
t.Fatalf("lock join = %v, want LOCKED-ROOM", r)
|
||||||
|
}
|
||||||
|
if r := asMap(t, hub.Handle(joiner, msg("joinroom", "name", "pw-room", "credential", "wrong"))); r["message"] != "WRONG-PASSWORD" {
|
||||||
|
t.Fatalf("pw wrong = %v, want WRONG-PASSWORD", r)
|
||||||
|
}
|
||||||
|
if r := asMap(t, hub.Handle(joiner, msg("joinroom", "name", "pw-room", "credential", "secret"))); r["status"] != "success" {
|
||||||
|
t.Fatalf("pw right = %v, want success", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoomIfExistsJoin(t *testing.T) {
|
||||||
|
hub := newHub()
|
||||||
|
a, _ := connect(hub, "a")
|
||||||
|
b, _ := connect(hub, "b")
|
||||||
|
|
||||||
|
common := []any{
|
||||||
|
"accessType", "public", "joinType", "free",
|
||||||
|
"notifyActionInvite", false, "notifyActionJoined", true, "notifyActionEjected", true,
|
||||||
|
"description", "d", "name", "shared",
|
||||||
|
}
|
||||||
|
if r := asMap(t, hub.Handle(a, msg("create-room", common...))); r["status"] != "success" {
|
||||||
|
t.Fatalf("first create = %v", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without ifexistsJoin: duplicate name fails.
|
||||||
|
if r := asMap(t, hub.Handle(b, msg("create-room", common...))); r["message"] != "ALREADY-EXISTS" {
|
||||||
|
t.Fatalf("dup create = %v, want ALREADY-EXISTS", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With ifexistsJoin: b joins the existing room instead of failing.
|
||||||
|
withFlag := append(append([]any{}, common...), "ifexistsJoin", true)
|
||||||
|
r := asMap(t, hub.Handle(b, msg("create-room", withFlag...)))
|
||||||
|
if r["status"] != "success" {
|
||||||
|
t.Fatalf("ifexistsJoin create = %v", r)
|
||||||
|
}
|
||||||
|
roomID := asMap(t, r["room"])["id"].(string)
|
||||||
|
if !b.InRoom(roomID) {
|
||||||
|
t.Fatal("b should have joined the existing room via ifexistsJoin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoomPerConnectionNotifySuppression(t *testing.T) {
|
||||||
|
hub := newHub()
|
||||||
|
a, fa := connect(hub, "a")
|
||||||
|
|
||||||
|
created := asMap(t, hub.Handle(a, msg("create-room",
|
||||||
|
"accessType", "public", "joinType", "free",
|
||||||
|
"notifyActionInvite", false, "notifyActionJoined", true, "notifyActionEjected", true,
|
||||||
|
"description", "d", "name", "R",
|
||||||
|
)))
|
||||||
|
if created["status"] != "success" {
|
||||||
|
t.Fatalf("create = %v", created)
|
||||||
|
}
|
||||||
|
|
||||||
|
// a opts out of peer-info notifications; it must NOT receive room/joined.
|
||||||
|
hub.Handle(a, msg("connection/pairinfo", "value", float64(0)))
|
||||||
|
beforeJoined := func() bool { _, ok := findSignal(fa, "room/joined"); return ok }()
|
||||||
|
|
||||||
|
b, _ := connect(hub, "b")
|
||||||
|
if r := asMap(t, hub.Handle(b, msg("joinroom", "name", "R"))); r["status"] != "success" {
|
||||||
|
t.Fatalf("b join = %v", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give any (erroneous) delivery time to land, then assert nothing new arrived.
|
||||||
|
waitFor(t, func() bool { return true })
|
||||||
|
if afterJoined := func() bool { _, ok := findSignal(fa, "room/joined"); return ok }(); afterJoined && !beforeJoined {
|
||||||
|
t.Fatal("a disabled pairinfo but still received room/joined")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoomInviteFlow(t *testing.T) {
|
||||||
|
hub := newHub()
|
||||||
|
owner, fo := connect(hub, "owner")
|
||||||
|
|
||||||
|
created := asMap(t, hub.Handle(owner, msg("create-room",
|
||||||
|
"accessType", "public", "joinType", "invite",
|
||||||
|
"notifyActionInvite", false, "notifyActionJoined", true, "notifyActionEjected", true,
|
||||||
|
"description", "d", "name", "club",
|
||||||
|
)))
|
||||||
|
roomID := asMap(t, created["room"])["id"].(string)
|
||||||
|
|
||||||
|
guest, fg := connect(hub, "guest")
|
||||||
|
resp := asMap(t, hub.Handle(guest, msg("joinroom", "name", "club")))
|
||||||
|
if resp["message"] != "INVITE-REQUESTED" {
|
||||||
|
t.Fatalf("invite join = %v, want INVITE-REQUESTED", resp)
|
||||||
|
}
|
||||||
|
if inv := waitSignal(t, fo, "room/invite"); inv["id"] != "guest" {
|
||||||
|
t.Fatalf("owner invite signal = %v", inv)
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted := asMap(t, hub.Handle(owner, msg("accept/invite-room", "roomId", roomID, "clientId", "guest")))
|
||||||
|
if accepted["status"] != "success" {
|
||||||
|
t.Fatalf("accept invite = %v", accepted)
|
||||||
|
}
|
||||||
|
if st := waitSignal(t, fg, "room/invite/status"); st["status"] != "accepted" {
|
||||||
|
t.Fatalf("guest status signal = %v", st)
|
||||||
|
}
|
||||||
|
if !guest.InRoom(roomID) {
|
||||||
|
t.Fatal("guest should be in the room after accepted invite")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- #28 Pairing parity --------------------------------------------------
|
||||||
|
|
||||||
|
func TestPairRejectAndEnd(t *testing.T) {
|
||||||
|
hub := newHub()
|
||||||
|
a, fa := connect(hub, "a")
|
||||||
|
b, _ := connect(hub, "b")
|
||||||
|
|
||||||
|
hub.Handle(a, msg("request/pair", "to", "b"))
|
||||||
|
hub.Handle(b, msg("accept/pair", "to", "a"))
|
||||||
|
if !isPaired(a, b) {
|
||||||
|
t.Fatal("precondition: a and b paired")
|
||||||
|
}
|
||||||
|
|
||||||
|
if r := asMap(t, hub.Handle(b, msg("end/pair", "to", "a"))); r["status"] != "success" {
|
||||||
|
t.Fatalf("end/pair = %v", r)
|
||||||
|
}
|
||||||
|
waitSignal(t, fa, "end/pair")
|
||||||
|
if isPaired(a, b) || a.HasPair("b") || b.HasPair("a") {
|
||||||
|
t.Fatal("end/pair should fully unpair both sides")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsReachable(t *testing.T) {
|
||||||
|
hub := newHub()
|
||||||
|
a, _ := connect(hub, "a")
|
||||||
|
b, _ := connect(hub, "b")
|
||||||
|
|
||||||
|
// b is public by default -> reachable.
|
||||||
|
if r := hub.Handle(a, msg("is/reachable", "to", "b")); r != true {
|
||||||
|
t.Fatalf("public reachable = %v, want true", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// b goes private -> not reachable until paired.
|
||||||
|
hub.Handle(b, msg("auth/private"))
|
||||||
|
if r := hub.Handle(a, msg("is/reachable", "to", "b")); r != false {
|
||||||
|
t.Fatalf("private unpaired reachable = %v, want false", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
hub.Handle(a, msg("request/pair", "to", "b"))
|
||||||
|
hub.Handle(b, msg("accept/pair", "to", "a"))
|
||||||
|
if r := hub.Handle(a, msg("is/reachable", "to", "b")); r != true {
|
||||||
|
t.Fatalf("private paired reachable = %v, want true", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPairListMutualOnly(t *testing.T) {
|
||||||
|
hub := newHub()
|
||||||
|
a, _ := connect(hub, "a")
|
||||||
|
b, _ := connect(hub, "b")
|
||||||
|
|
||||||
|
// One-directional request: not yet mutual, so pair/list is empty.
|
||||||
|
hub.Handle(a, msg("request/pair", "to", "b"))
|
||||||
|
if list := asMap(t, hub.Handle(a, msg("pair/list")))["value"]; list != nil {
|
||||||
|
if arr, ok := list.([]string); ok && len(arr) != 0 {
|
||||||
|
t.Fatalf("pair/list before accept = %v, want empty", arr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hub.Handle(b, msg("accept/pair", "to", "a"))
|
||||||
|
list, _ := asMap(t, hub.Handle(a, msg("pair/list")))["value"].([]string)
|
||||||
|
if len(list) != 1 || list[0] != "b" {
|
||||||
|
t.Fatalf("pair/list after accept = %v, want [b]", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- #29 Virtual addressing (IPPressure) parity --------------------------
|
||||||
|
|
||||||
|
func TestIPPressureReallocAndRelease(t *testing.T) {
|
||||||
|
hub := newHub()
|
||||||
|
a, _ := connect(hub, "a")
|
||||||
|
|
||||||
|
n1 := asMap(t, hub.Handle(a, msg("alloc/APNumber")))["number"].(int)
|
||||||
|
|
||||||
|
// realloc gives a different number and frees the old one.
|
||||||
|
n2 := asMap(t, hub.Handle(a, msg("realloc/APNumber")))["number"].(int)
|
||||||
|
if n1 == n2 {
|
||||||
|
t.Fatalf("realloc returned same number %d", n1)
|
||||||
|
}
|
||||||
|
if who := asMap(t, hub.Handle(a, msg("whois/APNumber", "whois", float64(n1)))); who["status"] != "fail" {
|
||||||
|
t.Fatalf("old number should be free after realloc, whois = %v", who)
|
||||||
|
}
|
||||||
|
|
||||||
|
// release clears it from the client and the table.
|
||||||
|
hub.Handle(a, msg("release/APNumber"))
|
||||||
|
if a.APNumber() != 0 {
|
||||||
|
t.Fatal("number not cleared on release")
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortcode + ip alloc work and are non-empty.
|
||||||
|
if code := asMap(t, hub.Handle(a, msg("alloc/APShortCode")))["code"].(string); len(code) != 3 {
|
||||||
|
t.Fatalf("shortcode = %q, want 3 letters", code)
|
||||||
|
}
|
||||||
|
if ip := asMap(t, hub.Handle(a, msg("alloc/APIPAddress")))["ip"].(string); ip == "" {
|
||||||
|
t.Fatal("ip alloc returned empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- leak hardening (high-scale, no unbounded growth) --------------------
|
||||||
|
|
||||||
|
func TestDisconnectCleansIncomingPairEdge(t *testing.T) {
|
||||||
|
hub := newHub()
|
||||||
|
a, _ := connect(hub, "a")
|
||||||
|
b, _ := connect(hub, "b")
|
||||||
|
|
||||||
|
// One-directional pending request: a -> b. a.pairs has b; b.pairedBy has a.
|
||||||
|
hub.Handle(a, msg("request/pair", "to", "b"))
|
||||||
|
if !a.HasPair("b") {
|
||||||
|
t.Fatal("precondition: a has outgoing edge to b")
|
||||||
|
}
|
||||||
|
|
||||||
|
// b disconnects without ever responding. a must not retain a stale edge.
|
||||||
|
hub.Disconnect(b)
|
||||||
|
if a.HasPair("b") {
|
||||||
|
t.Fatal("stale pair edge to a disconnected client was left behind (leak)")
|
||||||
|
}
|
||||||
|
if len(a.PairedBy()) != 0 {
|
||||||
|
t.Fatalf("a.PairedBy() = %v, want empty", a.PairedBy())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisconnectCleansWaitingList(t *testing.T) {
|
||||||
|
hub := newHub()
|
||||||
|
owner, _ := connect(hub, "owner")
|
||||||
|
created := asMap(t, hub.Handle(owner, msg("create-room",
|
||||||
|
"accessType", "public", "joinType", "invite",
|
||||||
|
"notifyActionInvite", false, "notifyActionJoined", true, "notifyActionEjected", true,
|
||||||
|
"description", "d", "name", "club",
|
||||||
|
)))
|
||||||
|
roomID := asMap(t, created["room"])["id"].(string)
|
||||||
|
|
||||||
|
guest, _ := connect(hub, "guest")
|
||||||
|
hub.Handle(guest, msg("joinroom", "name", "club"))
|
||||||
|
|
||||||
|
room, _ := hub.Room(roomID)
|
||||||
|
if !room.IsWaiting("guest") {
|
||||||
|
t.Fatal("precondition: guest should be on the waiting list")
|
||||||
|
}
|
||||||
|
|
||||||
|
hub.Disconnect(guest)
|
||||||
|
if room.IsWaiting("guest") {
|
||||||
|
t.Fatal("disconnected client left on the room waiting list (leak)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHighChurnLeavesNoResidualState drives many connect/pair/room/disconnect
|
||||||
|
// cycles and asserts the hub holds no clients or rooms afterwards — i.e. nothing
|
||||||
|
// accumulates across churn at scale.
|
||||||
|
func TestHighChurnLeavesNoResidualState(t *testing.T) {
|
||||||
|
hub := newHub()
|
||||||
|
|
||||||
|
for cycle := 0; cycle < 20; cycle++ {
|
||||||
|
roomName := fmt.Sprintf("room-%d", cycle)
|
||||||
|
clients := make([]*ws.Client, 0, 25)
|
||||||
|
for i := 0; i < 25; i++ {
|
||||||
|
c, _ := connect(hub, fmt.Sprintf("c%d-%d", cycle, i))
|
||||||
|
clients = append(clients, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
owner := clients[0]
|
||||||
|
hub.Handle(owner, msg("create-room",
|
||||||
|
"accessType", "public", "joinType", "free",
|
||||||
|
"notifyActionInvite", false, "notifyActionJoined", true, "notifyActionEjected", true,
|
||||||
|
"description", "d", "name", roomName,
|
||||||
|
))
|
||||||
|
for _, c := range clients[1:] {
|
||||||
|
hub.Handle(c, msg("joinroom", "name", roomName))
|
||||||
|
hub.Handle(c, msg("request/pair", "to", owner.ID))
|
||||||
|
hub.Handle(c, msg("alloc/APNumber"))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range clients {
|
||||||
|
hub.Disconnect(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := hub.ClientCount(); n != 0 {
|
||||||
|
t.Fatalf("residual clients after churn: %d (leak)", n)
|
||||||
|
}
|
||||||
|
if rooms := hub.Rooms(); len(rooms) != 0 {
|
||||||
|
t.Fatalf("residual rooms after churn: %d (leak)", len(rooms))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,296 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Clear any pending invites so a room's waiting list cannot retain the id
|
||||||
|
// of a client that has gone away.
|
||||||
|
for _, rid := range c.WaitingRooms() {
|
||||||
|
if r, ok := hub.Room(rid); ok {
|
||||||
|
r.RemoveWaiting(c.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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 existing, exists := hub.RoomByName(name); exists {
|
||||||
|
// ifexistsJoin: instead of failing on a name clash, join the existing
|
||||||
|
// room (so "create or join" is a single round trip).
|
||||||
|
if m.Truthy("ifexistsJoin") {
|
||||||
|
existing.Join(c)
|
||||||
|
return map[string]any{"status": "success", "room": existing.ToJSON(false)}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
c.AddWaitingRoom(room.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)
|
||||||
|
joinClient.RemoveWaitingRoom(room.ID)
|
||||||
|
|
||||||
|
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,140 @@
|
||||||
|
// 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/bridge"
|
||||||
|
"git.saqut.com/saqut/mwse/internal/datastore"
|
||||||
|
"git.saqut.com/saqut/mwse/internal/notify"
|
||||||
|
"git.saqut.com/saqut/mwse/internal/protocol"
|
||||||
|
"git.saqut.com/saqut/mwse/internal/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NotifyTrigger is invoked when a suit notification receives a reply, so MWSE can
|
||||||
|
// push the result to a 3rd-party application server (#44) instead of that server
|
||||||
|
// polling for it. The default is a no-op; a real HTTP-backed implementation lives
|
||||||
|
// in the bridge wiring (#46).
|
||||||
|
type NotifyTrigger interface {
|
||||||
|
NotifyReplied(n notify.Notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
type noopTrigger struct{}
|
||||||
|
|
||||||
|
func (noopTrigger) NotifyReplied(notify.Notification) {}
|
||||||
|
|
||||||
|
// options collects the externally-wired integrations a deployment may supply.
|
||||||
|
type options struct {
|
||||||
|
notifyTrigger NotifyTrigger
|
||||||
|
bridgeInbox *bridge.Inbox // nil = bridge/send not registered
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option configures Register.
|
||||||
|
type Option func(*options)
|
||||||
|
|
||||||
|
// WithNotifyTrigger sets the 3rd-party trigger fired on suit replies (#44).
|
||||||
|
func WithNotifyTrigger(t NotifyTrigger) Option {
|
||||||
|
return func(o *options) {
|
||||||
|
if t != nil {
|
||||||
|
o.notifyTrigger = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithBridgeInbox enables the bridge/send handler (#46). Clients can then
|
||||||
|
// route messages into the inbox, which the application server drains via
|
||||||
|
// POST /api/bridge/inbox.
|
||||||
|
func WithBridgeInbox(inbox *bridge.Inbox) Option {
|
||||||
|
return func(o *options) {
|
||||||
|
if inbox != nil {
|
||||||
|
o.bridgeInbox = inbox
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry exposes the long-lived stores and optional subsystems so the caller
|
||||||
|
// (main) can manage their lifecycle.
|
||||||
|
type Registry struct {
|
||||||
|
Notify *notify.Store
|
||||||
|
Data *datastore.Store
|
||||||
|
// Bridge is the inbox registered via WithBridgeInbox; nil when bridge is not
|
||||||
|
// configured. The HTTP layer drains it via POST /api/bridge/inbox.
|
||||||
|
Bridge *bridge.Inbox
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register wires every service onto the hub and returns a Registry of the stores
|
||||||
|
// that need lifecycle management. 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; the data-sync services (#43–#45) are appended after.
|
||||||
|
func Register(hub *ws.Hub, opts ...Option) *Registry {
|
||||||
|
o := options{notifyTrigger: noopTrigger{}}
|
||||||
|
for _, apply := range opts {
|
||||||
|
apply(&o)
|
||||||
|
}
|
||||||
|
|
||||||
|
registerYourID(hub)
|
||||||
|
registerAuth(hub)
|
||||||
|
registerRoom(hub)
|
||||||
|
registerDataTransfer(hub)
|
||||||
|
registerIPPressure(hub, nil)
|
||||||
|
registerSession(hub)
|
||||||
|
|
||||||
|
reg := &Registry{
|
||||||
|
Notify: registerNotify(hub, o.notifyTrigger),
|
||||||
|
Data: registerDatastore(hub),
|
||||||
|
}
|
||||||
|
if o.bridgeInbox != nil {
|
||||||
|
registerBridge(hub, o.bridgeInbox)
|
||||||
|
reg.Bridge = o.bridgeInbox
|
||||||
|
}
|
||||||
|
return reg
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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,25 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.saqut.com/saqut/mwse/internal/protocol"
|
||||||
|
"git.saqut.com/saqut/mwse/internal/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerYourID sends two signals on every new connection:
|
||||||
|
//
|
||||||
|
// 1. wsts/hello — version handshake. The SDK checks this version against its own
|
||||||
|
// constant (sdk/version.js) and refuses to proceed if they differ. The codecs
|
||||||
|
// list advertises which frame encodings the server supports (0 = JSON, 1 =
|
||||||
|
// binary once #42 is ready). This signal is sent first so the SDK can gate
|
||||||
|
// all subsequent processing on a successful version check.
|
||||||
|
//
|
||||||
|
// 2. id — the client's own socket id, exactly as the original Node YourID did.
|
||||||
|
func registerYourID(hub *ws.Hub) {
|
||||||
|
hub.OnConnect(func(c *ws.Client) {
|
||||||
|
c.Signal("wsts/hello", map[string]any{
|
||||||
|
"v": protocol.WSTSVersion,
|
||||||
|
"codecs": []int{protocol.WSTSCodecJSON},
|
||||||
|
})
|
||||||
|
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,436 @@
|
||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"git.saqut.com/saqut/mwse/internal/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultOutboundBuffer is the per-connection send queue depth used by NewClient
|
||||||
|
// (tests, tools). The server overrides it from configuration. It is kept high
|
||||||
|
// because the engine targets endless, bursty traffic; see config.ConnConfig for
|
||||||
|
// the memory trade-off.
|
||||||
|
const defaultOutboundBuffer = 1024
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
writeWait time.Duration // per-write socket deadline
|
||||||
|
|
||||||
|
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 toward (outgoing edges)
|
||||||
|
pairedBy map[string]struct{} // peers that paired toward this client (incoming edges)
|
||||||
|
waiting map[string]struct{} // rooms this client is awaiting an invite decision in
|
||||||
|
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 with default transport tuning. The
|
||||||
|
// server uses newClient to apply configured limits instead.
|
||||||
|
func NewClient(conn Conn, id string) *Client {
|
||||||
|
return newClient(conn, id, defaultOutboundBuffer, defaultWriteWait)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, outboundBuffer int, writeWait time.Duration) *Client {
|
||||||
|
if outboundBuffer <= 0 {
|
||||||
|
outboundBuffer = defaultOutboundBuffer
|
||||||
|
}
|
||||||
|
if writeWait <= 0 {
|
||||||
|
writeWait = defaultWriteWait
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
ID: id,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
conn: conn,
|
||||||
|
outbound: make(chan []byte, outboundBuffer),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
writeWait: writeWait,
|
||||||
|
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{}),
|
||||||
|
pairedBy: make(map[string]struct{}),
|
||||||
|
waiting: 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(c.writeWait))
|
||||||
|
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 -------------------------------------------------------------
|
||||||
|
|
||||||
|
// Pairing keeps two indexes so that a disconnect can clean up every edge that
|
||||||
|
// touches a client in O(degree) instead of scanning all clients — and so that
|
||||||
|
// stale ids never accumulate on long-lived clients under churn (the leak that
|
||||||
|
// would otherwise grow without bound at scale):
|
||||||
|
//
|
||||||
|
// - pairs : peers THIS client paired toward (outgoing)
|
||||||
|
// - pairedBy : peers that paired toward THIS client (incoming)
|
||||||
|
//
|
||||||
|
// AddPair/RemovePair take the other *Client and update both sides. The two locks
|
||||||
|
// are taken in separate, non-nested critical sections, so concurrent A.AddPair(B)
|
||||||
|
// and B.AddPair(A) cannot deadlock.
|
||||||
|
|
||||||
|
// AddPair records that this client has paired toward other, updating both the
|
||||||
|
// outgoing edge here and the incoming edge on other.
|
||||||
|
func (c *Client) AddPair(other *Client) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.pairs[other.ID] = struct{}{}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
other.mu.Lock()
|
||||||
|
other.pairedBy[c.ID] = struct{}{}
|
||||||
|
other.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemovePair drops the pairing edge from this client to other (and the matching
|
||||||
|
// incoming record on other).
|
||||||
|
func (c *Client) RemovePair(other *Client) {
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.pairs, other.ID)
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
other.mu.Lock()
|
||||||
|
delete(other.pairedBy, c.ID)
|
||||||
|
other.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForgetPeer removes peerID from both this client's outgoing and incoming pairing
|
||||||
|
// sets. Used during the other peer's disconnect cleanup.
|
||||||
|
func (c *Client) ForgetPeer(peerID string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.pairs, peerID)
|
||||||
|
delete(c.pairedBy, peerID)
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasPair reports whether this client has an outgoing 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 outgoing 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// PairedBy returns a snapshot of the peers that paired toward this client.
|
||||||
|
func (c *Client) PairedBy() []string {
|
||||||
|
c.mu.RLock()
|
||||||
|
out := make([]string, 0, len(c.pairedBy))
|
||||||
|
for id := range c.pairedBy {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
c.mu.RUnlock()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- invite waiting rooms -----------------------------------------------
|
||||||
|
//
|
||||||
|
// Tracking which rooms a client is awaiting an invite in lets disconnect clear
|
||||||
|
// those waiting lists, so a room's waiting set cannot grow with dead ids.
|
||||||
|
|
||||||
|
// AddWaitingRoom records that this client is awaiting an invite decision in room.
|
||||||
|
func (c *Client) AddWaitingRoom(roomID string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.waiting[roomID] = struct{}{}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveWaitingRoom clears a pending invite for room.
|
||||||
|
func (c *Client) RemoveWaitingRoom(roomID string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.waiting, roomID)
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitingRooms returns a snapshot of the rooms this client is awaiting in.
|
||||||
|
func (c *Client) WaitingRooms() []string {
|
||||||
|
c.mu.RLock()
|
||||||
|
out := make([]string, 0, len(c.waiting))
|
||||||
|
for id := range c.waiting {
|
||||||
|
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,281 @@
|
||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"git.saqut.com/saqut/mwse/internal/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Heartbeat and transport defaults used by DefaultOptions and NewClient. The
|
||||||
|
// PingPeriod matches the original server's 10s interval; the ping payload is the
|
||||||
|
// magic string the SDK's pong echoes.
|
||||||
|
const (
|
||||||
|
defaultWriteWait = 10 * time.Second
|
||||||
|
defaultPongWait = 60 * time.Second
|
||||||
|
defaultPingPeriod = 10 * time.Second
|
||||||
|
defaultMaxMessageSize = 16 << 20 // 16 MiB; supports large tunneled payloads (#30)
|
||||||
|
|
||||||
|
pingPayload = "saQut"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Approver decides whether to accept an incoming WebSocket connection. It is
|
||||||
|
// called before the HTTP upgrade with the pre-assigned client id and a metadata
|
||||||
|
// map derived from the request (ip, userAgent). Returning false causes a 403
|
||||||
|
// Forbidden response; the upgrade never happens. When nil, all connections are
|
||||||
|
// accepted (the original open-door behaviour).
|
||||||
|
type Approver interface {
|
||||||
|
Approve(id string, meta map[string]any) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options tunes the transport for scale. Zero fields fall back to defaults.
|
||||||
|
type Options struct {
|
||||||
|
OutboundBuffer int // per-connection send queue depth
|
||||||
|
MaxMessageSize int64 // max inbound frame size
|
||||||
|
ReadBufferSize int // gorilla per-connection read buffer
|
||||||
|
WriteBufferSize int // gorilla write buffer size (pooled)
|
||||||
|
PingInterval time.Duration // heartbeat period
|
||||||
|
PongWait time.Duration // max wait for a pong before dropping
|
||||||
|
WriteWait time.Duration // per-write socket deadline
|
||||||
|
// Approver is an optional connection gate. Nil accepts all connections.
|
||||||
|
Approver Approver
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultOptions returns the built-in tuning.
|
||||||
|
func DefaultOptions() Options {
|
||||||
|
return Options{
|
||||||
|
OutboundBuffer: defaultOutboundBuffer,
|
||||||
|
MaxMessageSize: defaultMaxMessageSize,
|
||||||
|
ReadBufferSize: 4096,
|
||||||
|
WriteBufferSize: 4096,
|
||||||
|
PingInterval: defaultPingPeriod,
|
||||||
|
PongWait: defaultPongWait,
|
||||||
|
WriteWait: defaultWriteWait,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o Options) withDefaults() Options {
|
||||||
|
d := DefaultOptions()
|
||||||
|
if o.OutboundBuffer <= 0 {
|
||||||
|
o.OutboundBuffer = d.OutboundBuffer
|
||||||
|
}
|
||||||
|
if o.MaxMessageSize <= 0 {
|
||||||
|
o.MaxMessageSize = d.MaxMessageSize
|
||||||
|
}
|
||||||
|
if o.ReadBufferSize <= 0 {
|
||||||
|
o.ReadBufferSize = d.ReadBufferSize
|
||||||
|
}
|
||||||
|
if o.WriteBufferSize <= 0 {
|
||||||
|
o.WriteBufferSize = d.WriteBufferSize
|
||||||
|
}
|
||||||
|
if o.PingInterval <= 0 {
|
||||||
|
o.PingInterval = d.PingInterval
|
||||||
|
}
|
||||||
|
if o.PongWait <= 0 {
|
||||||
|
o.PongWait = d.PongWait
|
||||||
|
}
|
||||||
|
if o.WriteWait <= 0 {
|
||||||
|
o.WriteWait = d.WriteWait
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
opts Options
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer returns a Server bound to hub. An optional Options tunes the
|
||||||
|
// transport; omit it for defaults.
|
||||||
|
//
|
||||||
|
// The upgrader uses a shared WriteBufferPool so write scratch buffers are reused
|
||||||
|
// across connections instead of allocated per connection — a large memory saving
|
||||||
|
// at high connection counts. CheckOrigin always returns true to preserve the
|
||||||
|
// original autoAcceptConnections behaviour (origin policy belongs in front of the
|
||||||
|
// engine).
|
||||||
|
func NewServer(hub *Hub, opts ...Options) *Server {
|
||||||
|
o := DefaultOptions()
|
||||||
|
if len(opts) > 0 {
|
||||||
|
o = opts[0].withDefaults()
|
||||||
|
}
|
||||||
|
return &Server{
|
||||||
|
hub: hub,
|
||||||
|
opts: o,
|
||||||
|
upgrader: websocket.Upgrader{
|
||||||
|
ReadBufferSize: o.ReadBufferSize,
|
||||||
|
WriteBufferSize: o.WriteBufferSize,
|
||||||
|
WriteBufferPool: &sync.Pool{},
|
||||||
|
CheckOrigin: func(*http.Request) bool { return true },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP implements http.Handler: it upgrades the request and hands the
|
||||||
|
// connection to the lifecycle. It is the WebSocket endpoint.
|
||||||
|
//
|
||||||
|
// When an Approver is configured it is consulted before the upgrade so the HTTP
|
||||||
|
// response can still carry a 403; once the upgrade succeeds the protocol is
|
||||||
|
// WebSocket and HTTP-level rejection is no longer possible.
|
||||||
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := newUUID()
|
||||||
|
if s.opts.Approver != nil {
|
||||||
|
meta := map[string]any{
|
||||||
|
"ip": r.RemoteAddr,
|
||||||
|
"userAgent": r.UserAgent(),
|
||||||
|
}
|
||||||
|
if !s.opts.Approver.Approve(id, meta) {
|
||||||
|
http.Error(w, "Connection rejected", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conn, err := s.upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ws: upgrade failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.handleWithID(conn, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWithID 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) handleWithID(conn Conn, id string) {
|
||||||
|
client := newClient(conn, id, s.opts.OutboundBuffer, s.opts.WriteWait)
|
||||||
|
|
||||||
|
// 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(s.opts.MaxMessageSize)
|
||||||
|
_ = c.conn.SetReadDeadline(time.Now().Add(s.opts.PongWait))
|
||||||
|
c.conn.SetPongHandler(func(appData string) error {
|
||||||
|
if appData != pingPayload {
|
||||||
|
return errBadPong
|
||||||
|
}
|
||||||
|
return c.conn.SetReadDeadline(time.Now().Add(s.opts.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)
|
||||||
|
|
||||||
|
// Reply only when the handler actually produced a result. A nil result marks a
|
||||||
|
// handler that is either fire-and-forget (a WOM relay such as pack/to without a
|
||||||
|
// handshake) or one that will be answered out-of-band later: request/to is
|
||||||
|
// answered by the peer's response/to frame carrying the *same* request id.
|
||||||
|
//
|
||||||
|
// Sending a premature [null, id, "E"] in those cases would resolve and then
|
||||||
|
// delete the SDK's pending request before the real answer arrived — exactly the
|
||||||
|
// #33 "promise hangs / gets clobbered" bug. Returning nil from a handler is the
|
||||||
|
// engine's explicit "no reply from me" signal. See decisions.md.
|
||||||
|
if flag, ok := env.WantsReply(); ok && result != nil {
|
||||||
|
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.opts.PingInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
err := c.conn.WriteControl(
|
||||||
|
websocket.PingMessage,
|
||||||
|
[]byte(pingPayload),
|
||||||
|
time.Now().Add(s.opts.WriteWait),
|
||||||
|
)
|
||||||
|
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,319 @@
|
||||||
|
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 TestPairReverseIndex(t *testing.T) {
|
||||||
|
a := NewClient(testutil.NewFakeConn(), "a")
|
||||||
|
b := NewClient(testutil.NewFakeConn(), "b")
|
||||||
|
|
||||||
|
a.AddPair(b)
|
||||||
|
if !a.HasPair("b") {
|
||||||
|
t.Fatal("a should have outgoing edge to b")
|
||||||
|
}
|
||||||
|
if got := b.PairedBy(); len(got) != 1 || got[0] != "a" {
|
||||||
|
t.Fatalf("b.PairedBy() = %v, want [a]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForgetPeer (used during a disconnect of the other side) must clear both
|
||||||
|
// directions so no stale id remains.
|
||||||
|
a.ForgetPeer("b")
|
||||||
|
if a.HasPair("b") {
|
||||||
|
t.Fatal("a should no longer reference b")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemovePair clears the outgoing edge and the matching incoming record.
|
||||||
|
a.AddPair(b)
|
||||||
|
a.RemovePair(b)
|
||||||
|
if a.HasPair("b") || len(b.PairedBy()) != 0 {
|
||||||
|
t.Fatal("RemovePair should clear both sides")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestServerNoReplyOnNilResult is the #33 regression at the engine level: a
|
||||||
|
// handler that returns nil (a fire-and-forget / WOM relay, or one that answers
|
||||||
|
// out-of-band) must NOT cause a premature [null, id, "E"] reply. Such a reply
|
||||||
|
// would resolve and discard the SDK's pending request before the real answer.
|
||||||
|
func TestServerNoReplyOnNilResult(t *testing.T) {
|
||||||
|
hub := NewHub()
|
||||||
|
hub.Register("wom", func(c *Client, m protocol.Message) any { return nil })
|
||||||
|
srv := NewServer(hub)
|
||||||
|
|
||||||
|
fc := testutil.NewFakeConn()
|
||||||
|
go srv.handleWithID(fc, newUUID())
|
||||||
|
|
||||||
|
fc.Push([]byte(`[{"type":"wom"}, 9, "R"]`))
|
||||||
|
// Give a wrong reply a chance to appear.
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
if fc.WriteCount() != 0 {
|
||||||
|
t.Fatalf("nil-returning handler produced %d reply frames, want 0", fc.WriteCount())
|
||||||
|
}
|
||||||
|
fc.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
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.handleWithID(fc, newUUID())
|
||||||
|
|
||||||
|
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,98 @@
|
||||||
|
// 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"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.saqut.com/saqut/mwse/internal/bridge"
|
||||||
|
"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()
|
||||||
|
|
||||||
|
// Bridge (3rd-party server integration, #46) — optional; activated by env vars.
|
||||||
|
// BRIDGE_APPROVE_URL: before each WebSocket upgrade, MWSE POSTs the candidate
|
||||||
|
// client id + metadata there; a non-200 or {"approve":false} body rejects it.
|
||||||
|
// BRIDGE_TRIGGER_URL: when a suit notification is replied to, MWSE POSTs the
|
||||||
|
// reply there so the app is notified immediately instead of polling.
|
||||||
|
var srvOpts httpserver.ServerOptions
|
||||||
|
var svcOpts []services.Option
|
||||||
|
if approveURL := os.Getenv("BRIDGE_APPROVE_URL"); approveURL != "" {
|
||||||
|
srvOpts.Approver = bridge.NewHTTPApprover(approveURL, 0)
|
||||||
|
log.Printf("bridge: connection approval delegated to %s", approveURL)
|
||||||
|
}
|
||||||
|
if triggerURL := os.Getenv("BRIDGE_TRIGGER_URL"); triggerURL != "" {
|
||||||
|
svcOpts = append(svcOpts, services.WithNotifyTrigger(bridge.NewHTTPTrigger(triggerURL, 0)))
|
||||||
|
log.Printf("bridge: suit-reply trigger wired to %s", triggerURL)
|
||||||
|
}
|
||||||
|
// The inbox is always created so bridge/send and POST /api/bridge/inbox are
|
||||||
|
// available once either bridge env var is set, or if the inbox is otherwise
|
||||||
|
// useful. When neither env var is set, the services option is simply not added
|
||||||
|
// and the bridge/send handler is not registered.
|
||||||
|
if srvOpts.Approver != nil || os.Getenv("BRIDGE_INBOX") != "" {
|
||||||
|
inbox := bridge.NewInbox(0)
|
||||||
|
svcOpts = append(svcOpts, services.WithBridgeInbox(inbox))
|
||||||
|
srvOpts.BridgeInbox = inbox
|
||||||
|
log.Printf("bridge: inbox enabled (POST /api/bridge/inbox)")
|
||||||
|
}
|
||||||
|
|
||||||
|
reg := services.Register(hub, svcOpts...)
|
||||||
|
|
||||||
|
// The notify and datastore stores hold entries with a TTL; their janitors
|
||||||
|
// reclaim expired ones so memory cannot grow without bound. They are stopped
|
||||||
|
// during shutdown so no goroutine is leaked.
|
||||||
|
stopNotify := reg.Notify.StartJanitor(time.Minute)
|
||||||
|
stopData := reg.Data.StartJanitor(time.Minute)
|
||||||
|
defer stopNotify()
|
||||||
|
defer stopData()
|
||||||
|
|
||||||
|
srv := httpserver.New(hub, cfg, srvOpts)
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
61
package.json
61
package.json
|
|
@ -1,61 +0,0 @@
|
||||||
{
|
|
||||||
"name": "mwse",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"description": "Mikro WebSocket Engine",
|
|
||||||
"scripts": {
|
|
||||||
"compile": "parcel watch --no-hmr",
|
|
||||||
"build": "parcel build --no-optimize"
|
|
||||||
},
|
|
||||||
"source": "./frontend/index.ts",
|
|
||||||
"targets": {
|
|
||||||
"default": {
|
|
||||||
"distDir": "./script/",
|
|
||||||
"publicUrl": "./",
|
|
||||||
"sourceMap": true,
|
|
||||||
"outputFormat": "global",
|
|
||||||
"optimize": true,
|
|
||||||
"context": "browser",
|
|
||||||
"engines": {
|
|
||||||
"chrome": "65",
|
|
||||||
"android": "4.4.3",
|
|
||||||
"edge": "16",
|
|
||||||
"firefox": "59",
|
|
||||||
"ie": "10",
|
|
||||||
"ios": "10",
|
|
||||||
"safari": "10"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "http://git.saqut.com/saqut/MWSE"
|
|
||||||
},
|
|
||||||
"keywords": [
|
|
||||||
"WebSocket",
|
|
||||||
"server",
|
|
||||||
"microservice",
|
|
||||||
"ws"
|
|
||||||
],
|
|
||||||
"author": "Abdussamed ULUTAŞ <abdussamedulutas@yandex.com.tr>",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"compression": "^1.7.4",
|
|
||||||
"express": "^4.18.2",
|
|
||||||
"express-basic-auth": "^1.2.1",
|
|
||||||
"fflate": "^0.8.1",
|
|
||||||
"joi": "^17.11.0",
|
|
||||||
"knex": "^3.0.1",
|
|
||||||
"sqlite3": "^5.1.6",
|
|
||||||
"systemjs": "^6.14.2",
|
|
||||||
"terminal": "^0.1.4",
|
|
||||||
"terminal-kit": "^3.0.0",
|
|
||||||
"typescript": "^5.2.2",
|
|
||||||
"webrtc-adapter": "^8.2.3",
|
|
||||||
"websocket": "^1.0.34"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@parcel/packager-ts": "^2.7.0",
|
|
||||||
"@parcel/transformer-typescript-types": "^2.7.0",
|
|
||||||
"tslib": "^2.4.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="tr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>MWSE — Sesli Görüşme Demo</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; max-width: 700px; margin: 2rem auto; padding: 0 1rem; }
|
||||||
|
h2 { margin-bottom: .3rem; }
|
||||||
|
.note { font-size: .85rem; color: #666; margin-bottom: 1rem; }
|
||||||
|
#status { padding: 6px 10px; background: #f5f5f5; border-radius: 4px;
|
||||||
|
font-size: .9rem; margin-bottom: 1rem; }
|
||||||
|
#peers { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||||
|
.peer-card { border: 1px solid #bbb; border-radius: 6px; padding: 10px 14px;
|
||||||
|
min-width: 160px; background: #fff; }
|
||||||
|
.peer-card .id { font-family: monospace; font-size: .8rem; color: #555; }
|
||||||
|
.peer-card .st { font-size: .85rem; margin-top: 4px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>MWSE Sesli Görüşme Demo</h2>
|
||||||
|
<p class="note">
|
||||||
|
Aynı anda birden fazla sekme ya da kullanıcı açın. Odaya katılan herkese
|
||||||
|
otomatik çift yönlü ses bağlantısı kurulur (P2P WebRTC mesh, küçük gruplar için).
|
||||||
|
</p>
|
||||||
|
<div id="status">Bağlanıyor…</div>
|
||||||
|
<div id="peers"></div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import MWSE from '/sdk/index.js';
|
||||||
|
|
||||||
|
const mwse = new MWSE();
|
||||||
|
let localStream;
|
||||||
|
const cards = {};
|
||||||
|
|
||||||
|
mwse.on('scope', async () => {
|
||||||
|
setStatus(`Bağlandı: ${mwse.me.socketId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Mikrofon erişimi reddedildi: ${err.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = mwse.room({ name: 'sesli', joinType: 'free', ifexistsJoin: true });
|
||||||
|
await room.createRoom();
|
||||||
|
setStatus(`Odada: sesli | Kimliğim: ${mwse.me.socketId}`);
|
||||||
|
|
||||||
|
// Odaya yeni biri katıldığında — biz eşleme isteği atalım.
|
||||||
|
room.on('join', async peer => {
|
||||||
|
upsertCard(peer.socketId, 'eşleniyor…');
|
||||||
|
await peer.requestPair();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bize gelen eşleme isteğini otomatik kabul et.
|
||||||
|
mwse.me.on('request/pair', async peer => {
|
||||||
|
upsertCard(peer.socketId, 'eşleniyor…');
|
||||||
|
await peer.acceptPair();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Eşleme kabul edildiğinde — WebRTC sesini başlat.
|
||||||
|
mwse.me.on('accepted/pair', peer => startAudio(peer));
|
||||||
|
mwse.me.on('end/pair', (id) => upsertCard(id, 'ayrıldı'));
|
||||||
|
});
|
||||||
|
|
||||||
|
function startAudio(peer) {
|
||||||
|
upsertCard(peer.socketId, 'bağlanıyor…');
|
||||||
|
const rtc = peer.rtc;
|
||||||
|
|
||||||
|
// Politeness: lexicographic comparison so exactly one side is polite.
|
||||||
|
const polite = mwse.me.socketId < peer.socketId;
|
||||||
|
rtc.connect({ polite });
|
||||||
|
|
||||||
|
// Mikrofon akışını gönder.
|
||||||
|
if (localStream) rtc.addStream('mic', localStream);
|
||||||
|
|
||||||
|
// Gelen ses track'lerini çal.
|
||||||
|
rtc.on('track', (track) => {
|
||||||
|
if (track.kind !== 'audio') return;
|
||||||
|
const audio = new Audio();
|
||||||
|
audio.autoplay = true;
|
||||||
|
audio.srcObject = new MediaStream([track]);
|
||||||
|
document.body.appendChild(audio);
|
||||||
|
upsertCard(peer.socketId, '🔊 konuşuyor');
|
||||||
|
});
|
||||||
|
|
||||||
|
rtc.on('connected', () => upsertCard(peer.socketId, '🟢 bağlandı'));
|
||||||
|
rtc.on('disconnected', () => upsertCard(peer.socketId, '🔴 kesildi'));
|
||||||
|
rtc.on('failed', () => upsertCard(peer.socketId, '⚠️ başarısız'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertCard(id, state) {
|
||||||
|
if (!cards[id]) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'peer-card';
|
||||||
|
card.innerHTML = `<div class="id">${id}</div><div class="st"></div>`;
|
||||||
|
document.getElementById('peers').appendChild(card);
|
||||||
|
cards[id] = card;
|
||||||
|
}
|
||||||
|
cards[id].querySelector('.st').textContent = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(msg) {
|
||||||
|
document.getElementById('status').textContent = msg;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="tr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>MWSE — Chat Demo</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
|
||||||
|
h2 { margin-bottom: .5rem; }
|
||||||
|
#status { font-size: .85rem; color: #666; margin-bottom: .5rem; }
|
||||||
|
#log { border: 1px solid #ccc; height: 260px; overflow-y: auto; padding: 8px;
|
||||||
|
margin-bottom: 8px; border-radius: 4px; }
|
||||||
|
#log p { margin: 2px 0; font-size: .9rem; }
|
||||||
|
#log .system { color: #888; font-style: italic; }
|
||||||
|
#log .me { color: #006; font-weight: bold; }
|
||||||
|
#log .peer { color: #060; }
|
||||||
|
#controls { display: flex; gap: 6px; }
|
||||||
|
#msg { flex: 1; padding: 6px; }
|
||||||
|
button { padding: 6px 14px; cursor: pointer; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>MWSE Chat Demo</h2>
|
||||||
|
<p id="status">Bağlanıyor…</p>
|
||||||
|
<div id="log"></div>
|
||||||
|
<div id="controls">
|
||||||
|
<input id="msg" placeholder="Mesaj yaz… (Enter)" onkeydown="if(event.key==='Enter') send()">
|
||||||
|
<button onclick="send()">Gönder</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import MWSE from '/sdk/index.js';
|
||||||
|
|
||||||
|
// ~20 satır JS ile odalı gerçek zamanlı sohbet.
|
||||||
|
// Birden fazla sekme / kullanıcı aynı "genel" odasına otomatik katılır.
|
||||||
|
|
||||||
|
const mwse = new MWSE();
|
||||||
|
let room;
|
||||||
|
|
||||||
|
mwse.on('scope', async () => {
|
||||||
|
document.getElementById('status').textContent =
|
||||||
|
`Bağlandı: ${mwse.me.socketId}`;
|
||||||
|
|
||||||
|
room = mwse.room({ name: 'genel', joinType: 'free', ifexistsJoin: true });
|
||||||
|
await room.createRoom();
|
||||||
|
|
||||||
|
room.on('join', peer => log(`${peer.socketId} odaya katıldı`, 'system'));
|
||||||
|
room.on('eject', peer => log(`${peer.socketId} odadan ayrıldı`, 'system'));
|
||||||
|
room.on('message', (pack, peer) => log(`${peer.socketId}: ${pack.text}`, 'peer'));
|
||||||
|
});
|
||||||
|
|
||||||
|
window.send = function() {
|
||||||
|
const el = document.getElementById('msg');
|
||||||
|
const text = el.value.trim();
|
||||||
|
if (!text || !room) return;
|
||||||
|
room.send({ text });
|
||||||
|
log(`ben: ${text}`, 'me');
|
||||||
|
el.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(text, cls = '') {
|
||||||
|
const p = Object.assign(document.createElement('p'), { textContent: text, className: cls });
|
||||||
|
const box = document.getElementById('log');
|
||||||
|
box.appendChild(p);
|
||||||
|
box.scrollTop = box.scrollHeight;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="tr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>MWSE — Video Demo</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: sans-serif; margin: 0; background: #111; color: #eee; }
|
||||||
|
#header { padding: 10px 16px; background: #1a1a1a; display: flex; align-items: center; gap: 12px; }
|
||||||
|
#header h2 { margin: 0; font-size: 1.1rem; }
|
||||||
|
#status { font-size: .8rem; color: #aaa; }
|
||||||
|
#grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 6px; padding: 10px; }
|
||||||
|
.tile { background: #222; border-radius: 6px; overflow: hidden; position: relative;
|
||||||
|
aspect-ratio: 16/9; }
|
||||||
|
.tile video { width: 100%; height: 100%; object-fit: cover; background: #000; }
|
||||||
|
.tile .label { position: absolute; bottom: 6px; left: 6px; font-size: .75rem;
|
||||||
|
background: rgba(0,0,0,.6); padding: 2px 6px; border-radius: 3px; }
|
||||||
|
.tile .dot { position: absolute; top: 6px; right: 6px; width: 10px; height: 10px;
|
||||||
|
border-radius: 50%; background: #f44; }
|
||||||
|
.tile.ok .dot { background: #4c4; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="header">
|
||||||
|
<h2>MWSE Video Demo</h2>
|
||||||
|
<span id="status">Bağlanıyor…</span>
|
||||||
|
</div>
|
||||||
|
<div id="grid"></div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import MWSE from '/sdk/index.js';
|
||||||
|
|
||||||
|
// Akış: bağlan → kamera aç → kendi tile'ı ekle → odaya katıl →
|
||||||
|
// her eşle P2P WebRTC bağlantısı kur.
|
||||||
|
// Ölçek notu: mesh topolojisi ~6–8 kişiye makul çalışır.
|
||||||
|
// Daha büyük odalar için SFU (SRS) gerekir — bkz. #39.
|
||||||
|
|
||||||
|
const mwse = new MWSE();
|
||||||
|
let localStream;
|
||||||
|
const tiles = {};
|
||||||
|
|
||||||
|
mwse.on('scope', async () => {
|
||||||
|
setStatus(`Bağlandı: ${mwse.me.socketId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
|
||||||
|
addTile('ben', localStream, true);
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Kamera erişimi reddedildi: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = mwse.room({ name: 'video', joinType: 'free', ifexistsJoin: true });
|
||||||
|
await room.createRoom();
|
||||||
|
setStatus(`Odada: video | ${mwse.me.socketId.slice(-8)}`);
|
||||||
|
|
||||||
|
room.on('join', async peer => {
|
||||||
|
await peer.requestPair();
|
||||||
|
});
|
||||||
|
|
||||||
|
mwse.me.on('request/pair', async peer => {
|
||||||
|
await peer.acceptPair();
|
||||||
|
});
|
||||||
|
|
||||||
|
mwse.me.on('accepted/pair', peer => startVideo(peer));
|
||||||
|
mwse.me.on('end/pair', id => removeTile(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
function startVideo(peer) {
|
||||||
|
const rtc = peer.rtc;
|
||||||
|
const polite = mwse.me.socketId < peer.socketId;
|
||||||
|
rtc.connect({ polite });
|
||||||
|
|
||||||
|
if (localStream) rtc.addStream('cam', localStream);
|
||||||
|
|
||||||
|
rtc.on('track', (track, streams) => {
|
||||||
|
if (track.kind !== 'video') return;
|
||||||
|
const stream = streams?.[0] ?? new MediaStream([track]);
|
||||||
|
addTile(peer.socketId.slice(-8), stream, false);
|
||||||
|
setOk(peer.socketId, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
rtc.on('connected', () => setOk(peer.socketId, true));
|
||||||
|
rtc.on('disconnected', () => setOk(peer.socketId, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTile(label, stream, isMe) {
|
||||||
|
const key = isMe ? 'me' : label;
|
||||||
|
if (tiles[key]) return;
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'tile' + (isMe ? ' ok' : '');
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.autoplay = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
if (isMe) video.muted = true;
|
||||||
|
if (stream) video.srcObject = stream;
|
||||||
|
const lbl = document.createElement('div');
|
||||||
|
lbl.className = 'label';
|
||||||
|
lbl.textContent = isMe ? `★ ${label}` : label;
|
||||||
|
const dot = document.createElement('div');
|
||||||
|
dot.className = 'dot';
|
||||||
|
div.append(video, lbl, dot);
|
||||||
|
document.getElementById('grid').appendChild(div);
|
||||||
|
tiles[key] = div;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOk(id, ok) {
|
||||||
|
const tile = tiles[id.slice(-8)] ?? tiles[id];
|
||||||
|
if (tile) tile.classList.toggle('ok', ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTile(id) {
|
||||||
|
const key = (id || '').slice(-8);
|
||||||
|
for (const k of [key, id]) {
|
||||||
|
if (tiles[k]) { tiles[k].remove(); delete tiles[k]; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(msg) {
|
||||||
|
document.getElementById('status').textContent = msg;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
// A single column in the Miller-column navigator.
|
||||||
|
//
|
||||||
|
// Each column holds a list of Item objects. Selecting an item calls
|
||||||
|
// item.onSelect() and marks the item as active (highlighted). The column
|
||||||
|
// also supports optional search filtering.
|
||||||
|
export default class Column {
|
||||||
|
constructor({ title, items = [], searchable = true }) {
|
||||||
|
this.title = title;
|
||||||
|
this._items = items;
|
||||||
|
this._active = null; // currently selected item element
|
||||||
|
this._root = null;
|
||||||
|
this._list = null;
|
||||||
|
this._searchable = searchable;
|
||||||
|
this._filter = '';
|
||||||
|
this._actionsEl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build and return the column DOM element.
|
||||||
|
mount() {
|
||||||
|
const col = document.createElement('div');
|
||||||
|
col.className = 'mwse-col';
|
||||||
|
this._root = col;
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'mwse-col__header';
|
||||||
|
header.textContent = this.title;
|
||||||
|
col.appendChild(header);
|
||||||
|
|
||||||
|
if (this._searchable && this._items.length > 6) {
|
||||||
|
const search = document.createElement('input');
|
||||||
|
search.type = 'text';
|
||||||
|
search.className = 'mwse-col__search';
|
||||||
|
search.placeholder = 'Filter…';
|
||||||
|
search.addEventListener('input', () => {
|
||||||
|
this._filter = search.value.toLowerCase();
|
||||||
|
this._renderItems();
|
||||||
|
});
|
||||||
|
col.appendChild(search);
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'mwse-col__list';
|
||||||
|
this._list = list;
|
||||||
|
col.appendChild(list);
|
||||||
|
|
||||||
|
this._renderItems();
|
||||||
|
return col;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the item list (re-renders the list area).
|
||||||
|
setItems(items) {
|
||||||
|
this._items = items;
|
||||||
|
this._active = null;
|
||||||
|
this._filter = '';
|
||||||
|
this._renderItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a persistent action button below the list.
|
||||||
|
addAction(label, className, onClick) {
|
||||||
|
if (!this._root) return;
|
||||||
|
if (!this._actionsEl) {
|
||||||
|
this._actionsEl = document.createElement('div');
|
||||||
|
this._actionsEl.className = 'mwse-col__actions';
|
||||||
|
this._root.appendChild(this._actionsEl);
|
||||||
|
}
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = `mwse-btn ${className ?? ''}`;
|
||||||
|
btn.innerHTML = label; // HTML destekler (ikonlu etiketler için)
|
||||||
|
btn.addEventListener('click', onClick);
|
||||||
|
this._actionsEl.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force a re-render (e.g. after external state changes meta text).
|
||||||
|
refresh() {
|
||||||
|
this._renderItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Private --------------------------------------------------------
|
||||||
|
|
||||||
|
_renderItems() {
|
||||||
|
if (!this._list) return;
|
||||||
|
this._list.innerHTML = '';
|
||||||
|
|
||||||
|
const visible = this._filter
|
||||||
|
? this._items.filter(i => i.label.toLowerCase().includes(this._filter))
|
||||||
|
: this._items;
|
||||||
|
|
||||||
|
for (const item of visible) {
|
||||||
|
this._list.appendChild(this._buildItem(item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildItem(item) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'mwse-item';
|
||||||
|
|
||||||
|
const icon = document.createElement('span');
|
||||||
|
icon.className = 'mwse-item__icon';
|
||||||
|
// Material Icons: icon adı string ise text, HTML içeriyorsa innerHTML
|
||||||
|
if (item.icon?.includes('<')) {
|
||||||
|
icon.innerHTML = item.icon;
|
||||||
|
} else {
|
||||||
|
icon.textContent = item.icon ?? 'circle';
|
||||||
|
}
|
||||||
|
row.appendChild(icon);
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'mwse-item__body';
|
||||||
|
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.className = 'mwse-item__label';
|
||||||
|
label.textContent = item.label;
|
||||||
|
body.appendChild(label);
|
||||||
|
|
||||||
|
if (item.meta !== undefined) {
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'mwse-item__meta';
|
||||||
|
meta.textContent = typeof item.meta === 'function' ? item.meta() : item.meta;
|
||||||
|
body.appendChild(meta);
|
||||||
|
item._metaEl = meta; // for refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
row.appendChild(body);
|
||||||
|
|
||||||
|
if (item.hasChildren !== false) {
|
||||||
|
const arrow = document.createElement('span');
|
||||||
|
arrow.className = 'mwse-item__arrow';
|
||||||
|
arrow.textContent = 'chevron_right';
|
||||||
|
row.appendChild(arrow);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.addEventListener('click', () => {
|
||||||
|
// Deactivate previous selection.
|
||||||
|
if (this._active) this._active.classList.remove('mwse-item--active');
|
||||||
|
row.classList.add('mwse-item--active');
|
||||||
|
this._active = row;
|
||||||
|
item.onSelect?.(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
// Miller-column view: manages a horizontal row of up to 5 columns.
|
||||||
|
// Selecting an item in column N removes columns N+1…end and adds the
|
||||||
|
// new column opened by that item's onSelect callback.
|
||||||
|
import Column from '/studio/Column.js';
|
||||||
|
|
||||||
|
export default class ColumnView {
|
||||||
|
constructor(container) {
|
||||||
|
this._container = container;
|
||||||
|
this._columns = []; // Column instances in order
|
||||||
|
this._els = []; // corresponding DOM elements
|
||||||
|
this._root = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
mount() {
|
||||||
|
const root = document.createElement('div');
|
||||||
|
root.className = 'mwse-studio__columns';
|
||||||
|
this._root = root;
|
||||||
|
this._container.appendChild(root);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push a new column to the right. Returns the Column.
|
||||||
|
pushColumn(title, items, opts = {}) {
|
||||||
|
const col = new Column({ title, items, ...opts });
|
||||||
|
const el = col.mount();
|
||||||
|
|
||||||
|
this._columns.push(col);
|
||||||
|
this._els.push(el);
|
||||||
|
this._root.appendChild(el);
|
||||||
|
|
||||||
|
// Mark the new column as active (rightmost).
|
||||||
|
this._els.forEach((e, i) => {
|
||||||
|
e.classList.toggle('mwse-col--active', i === this._els.length - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll so the new column is fully visible.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this._root.scrollLeft = this._root.scrollWidth;
|
||||||
|
});
|
||||||
|
|
||||||
|
return col;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all columns from index `depth` onwards, then push `title` + `items`.
|
||||||
|
replaceFrom(depth, title, items, opts) {
|
||||||
|
this._truncateTo(depth);
|
||||||
|
return this.pushColumn(title, items, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove columns deeper than `depth` (0-based).
|
||||||
|
// Useful when the user navigates back by clicking an earlier column item.
|
||||||
|
truncateTo(depth) {
|
||||||
|
this._truncateTo(depth + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pop the rightmost column.
|
||||||
|
popColumn() {
|
||||||
|
if (this._columns.length === 0) return;
|
||||||
|
const el = this._els.pop();
|
||||||
|
this._columns.pop();
|
||||||
|
el.remove();
|
||||||
|
if (this._els.length) {
|
||||||
|
this._els[this._els.length - 1].classList.add('mwse-col--active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all columns from index `toDepth` (exclusive) onwards.
|
||||||
|
popTo(toDepth) {
|
||||||
|
while (this._columns.length > toDepth) this.popColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force all columns to re-render their meta text.
|
||||||
|
refresh() {
|
||||||
|
for (const col of this._columns) col.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
get depth() { return this._columns.length; }
|
||||||
|
|
||||||
|
col(i) { return this._columns[i] ?? null; }
|
||||||
|
|
||||||
|
// ---- Internal -------------------------------------------------------
|
||||||
|
|
||||||
|
_truncateTo(count) {
|
||||||
|
while (this._columns.length > count) {
|
||||||
|
const el = this._els.pop();
|
||||||
|
this._columns.pop();
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
if (this._els.length) {
|
||||||
|
this._els[this._els.length - 1].classList.add('mwse-col--active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,823 @@
|
||||||
|
// MWSE Studio — dahili yönetim arayüzü.
|
||||||
|
import ColumnView from '/studio/ColumnView.js';
|
||||||
|
import { MediaSources } from '/sdk/webrtc/index.js';
|
||||||
|
|
||||||
|
export default class Studio {
|
||||||
|
constructor(mwse, container) {
|
||||||
|
this.mwse = mwse;
|
||||||
|
this._el = typeof container === 'string'
|
||||||
|
? document.querySelector(container) : container;
|
||||||
|
this._view = null;
|
||||||
|
this._devices = { cameras: [], microphones: [] };
|
||||||
|
this._statusEl = null;
|
||||||
|
this._notifArea = null;
|
||||||
|
this._myIPEl = null;
|
||||||
|
this._myUUIDEl = null;
|
||||||
|
this._localGrid = null;
|
||||||
|
this._remoteGrid = null;
|
||||||
|
this._streamsPanel = null;
|
||||||
|
this._styleOK = false;
|
||||||
|
this._peersCol = null; // reaktif güncelleme için referans
|
||||||
|
}
|
||||||
|
|
||||||
|
async mount() {
|
||||||
|
this._el.classList.add('mwse-studio');
|
||||||
|
this._injectStyle();
|
||||||
|
this._buildToolbar();
|
||||||
|
this._buildNotifArea();
|
||||||
|
|
||||||
|
// Ana alan: kolonlar (sol) + akış monitörü (sağ)
|
||||||
|
const mainArea = document.createElement('div');
|
||||||
|
mainArea.className = 'mwse-studio__main';
|
||||||
|
this._el.appendChild(mainArea);
|
||||||
|
|
||||||
|
this._view = new ColumnView(mainArea);
|
||||||
|
this._view.mount();
|
||||||
|
|
||||||
|
this._streamsPanel = this._buildStreamsPanel(mainArea);
|
||||||
|
|
||||||
|
await this._loadDevices();
|
||||||
|
this._pushRootColumn();
|
||||||
|
|
||||||
|
// Sanal IP al
|
||||||
|
this.mwse.scope(async () => {
|
||||||
|
try {
|
||||||
|
const ip = await this.mwse.virtualPressure.allocAPIPAddress();
|
||||||
|
this.mwse.me.info.set('ip', ip);
|
||||||
|
if (this._myIPEl) this._myIPEl.textContent = ip;
|
||||||
|
if (this._myUUIDEl) this._myUUIDEl.textContent = this.mwse.me.socketId.slice(-8);
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gelen eşleme isteği → bildirim banner
|
||||||
|
this.mwse.me.on('request/pair', peer => this._showPairRequest(peer));
|
||||||
|
|
||||||
|
// Eşleşme onaylandı (istek gönderen taraf)
|
||||||
|
this.mwse.me.on('accepted/pair', peer => {
|
||||||
|
this._watchIncoming(peer);
|
||||||
|
if (this.mwse.virtualPressure.APIPAddress) {
|
||||||
|
this.mwse.me.info.set('ip', this.mwse.virtualPressure.APIPAddress);
|
||||||
|
}
|
||||||
|
this._rebuildPeerItems();
|
||||||
|
this._setStatus('online', `${this._peerLabel(peer)} eşleşmesi kuruldu`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Eşleşme bitti — tiles + RTC (SDK zaten destroy çağırdı, ama tiles Studio'ya ait)
|
||||||
|
this.mwse.me.on('end/pair', (from) => {
|
||||||
|
if (typeof from === 'string') this._clearPeerTiles(from);
|
||||||
|
this._rebuildPeerItems();
|
||||||
|
this._setStatus('', `${typeof from === 'string' ? from.slice(-8) : ''} ayrıldı`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// WebSocket koptu → RTC da kapandı (SDK destroy çağırdı)
|
||||||
|
this.mwse.me.on('peer/disconnect', peer => {
|
||||||
|
this._clearPeerTiles(peer.socketId);
|
||||||
|
this._rebuildPeerItems();
|
||||||
|
this._setStatus('error', `${this._peerLabel(peer)} bağlantısı kesildi`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.mwse.on('room', () => this._view.refresh());
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Araç çubuğu ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_buildToolbar() {
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'mwse-studio__toolbar';
|
||||||
|
|
||||||
|
const logo = document.createElement('span');
|
||||||
|
logo.className = 'mwse-studio__title';
|
||||||
|
logo.innerHTML = 'MWSE <span style="color:#0078d4">Studio</span>';
|
||||||
|
bar.appendChild(logo);
|
||||||
|
|
||||||
|
// ID kartı: sadece IP + kısa UUID + kopyala
|
||||||
|
const idCard = document.createElement('div');
|
||||||
|
idCard.className = 'mwse-id-card';
|
||||||
|
idCard.title = 'Socket ID kopyala (bağlantı paylaşımı için)';
|
||||||
|
|
||||||
|
this._myIPEl = document.createElement('span');
|
||||||
|
this._myIPEl.className = 'mwse-id-card__ip';
|
||||||
|
this._myIPEl.textContent = '●'; // IP gelmeden önce nokta
|
||||||
|
|
||||||
|
this._myUUIDEl = document.createElement('span');
|
||||||
|
this._myUUIDEl.className = 'mwse-id-card__value';
|
||||||
|
this._myUUIDEl.textContent = '…';
|
||||||
|
|
||||||
|
const copyIcon = document.createElement('span');
|
||||||
|
copyIcon.className = 'mwse-id-card__copy';
|
||||||
|
copyIcon.textContent = '⎘';
|
||||||
|
|
||||||
|
idCard.append(this._myIPEl, this._myUUIDEl, copyIcon);
|
||||||
|
idCard.addEventListener('click', () => {
|
||||||
|
const id = this.mwse.me.socketId;
|
||||||
|
if (!id) return;
|
||||||
|
navigator.clipboard.writeText(id).then(() => {
|
||||||
|
copyIcon.textContent = '✓';
|
||||||
|
idCard.classList.add('mwse-id-card--copied');
|
||||||
|
setTimeout(() => {
|
||||||
|
idCard.classList.remove('mwse-id-card--copied');
|
||||||
|
copyIcon.textContent = '⎘';
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
bar.appendChild(idCard);
|
||||||
|
|
||||||
|
this._statusEl = document.createElement('span');
|
||||||
|
this._statusEl.className = 'mwse-studio__status mwse-studio__status--online';
|
||||||
|
this._statusEl.innerHTML = '<span class="mi mi-sm">wifi</span> Bağlı';
|
||||||
|
bar.appendChild(this._statusEl);
|
||||||
|
|
||||||
|
// Canlı saat
|
||||||
|
const clock = document.createElement('span');
|
||||||
|
clock.className = 'mwse-studio__clock';
|
||||||
|
const tick = () => {
|
||||||
|
const now = new Date();
|
||||||
|
clock.textContent = now.toTimeString().slice(0, 8);
|
||||||
|
};
|
||||||
|
tick(); setInterval(tick, 1000);
|
||||||
|
bar.appendChild(clock);
|
||||||
|
|
||||||
|
this._el.insertBefore(bar, this._el.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Akış monitör paneli ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_buildStreamsPanel(parent) {
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.className = 'mwse-streams-panel';
|
||||||
|
panel.style.display = 'none'; // akış yokken gizli
|
||||||
|
|
||||||
|
// Gönderiyorum bölümü
|
||||||
|
const outSec = document.createElement('div');
|
||||||
|
outSec.className = 'mwse-streams-section';
|
||||||
|
const outTitle = document.createElement('div');
|
||||||
|
outTitle.className = 'mwse-streams-section__title';
|
||||||
|
outTitle.textContent = '▲ Gönderiyorum';
|
||||||
|
this._localGrid = document.createElement('div');
|
||||||
|
this._localGrid.className = 'mwse-streams-grid';
|
||||||
|
outSec.append(outTitle, this._localGrid);
|
||||||
|
|
||||||
|
// Geliyor bölümü
|
||||||
|
const inSec = document.createElement('div');
|
||||||
|
inSec.className = 'mwse-streams-section';
|
||||||
|
const inTitle = document.createElement('div');
|
||||||
|
inTitle.className = 'mwse-streams-section__title';
|
||||||
|
inTitle.textContent = '▼ Geliyor';
|
||||||
|
this._remoteGrid = document.createElement('div');
|
||||||
|
this._remoteGrid.className = 'mwse-streams-grid';
|
||||||
|
inSec.append(inTitle, this._remoteGrid);
|
||||||
|
|
||||||
|
panel.append(outSec, inSec);
|
||||||
|
parent.appendChild(panel);
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yerel (gönderilen) akış tile'ı ekle
|
||||||
|
_addLocalTile(label, stream, peer) {
|
||||||
|
const peerLabel = typeof peer === 'string' ? peer : this._peerLabel(peer);
|
||||||
|
const peerId = peer?.socketId ?? peerLabel;
|
||||||
|
const hasVideo = stream.getVideoTracks().length > 0;
|
||||||
|
const tile = this._makeTile(
|
||||||
|
label,
|
||||||
|
`→ ${peerLabel}`,
|
||||||
|
stream,
|
||||||
|
hasVideo,
|
||||||
|
true, // muted (geri besleme yok)
|
||||||
|
() => { tile.remove(); this._updatePanelVisibility(); }
|
||||||
|
);
|
||||||
|
tile.dataset.peerId = peerId;
|
||||||
|
this._localGrid.appendChild(tile);
|
||||||
|
this._updatePanelVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uzak (gelen) track tile'ı → ses kontrollü
|
||||||
|
_addRemoteTile(track, streams, peer) {
|
||||||
|
const peerLabel = typeof peer === 'string' ? peer : this._peerLabel(peer);
|
||||||
|
const peerId = peer?.socketId ?? peerLabel;
|
||||||
|
const isVideo = track.kind === 'video';
|
||||||
|
const stream = streams?.[0] ?? new MediaStream([track]);
|
||||||
|
|
||||||
|
const tile = document.createElement('div');
|
||||||
|
tile.className = `mwse-stream-tile${isVideo ? '' : ' mwse-stream-tile--audio'}`;
|
||||||
|
|
||||||
|
// Media element
|
||||||
|
let mediaEl;
|
||||||
|
if (isVideo) {
|
||||||
|
mediaEl = document.createElement('video');
|
||||||
|
mediaEl.autoplay = true;
|
||||||
|
mediaEl.playsInline = true;
|
||||||
|
mediaEl.muted = false;
|
||||||
|
mediaEl.srcObject = stream;
|
||||||
|
mediaEl.className = 'mwse-stream-tile__video';
|
||||||
|
tile.appendChild(mediaEl);
|
||||||
|
} else {
|
||||||
|
// Ses: görünür tile + gizli <audio> elemanı
|
||||||
|
mediaEl = document.createElement('audio');
|
||||||
|
mediaEl.autoplay = true;
|
||||||
|
mediaEl.muted = false;
|
||||||
|
mediaEl.srcObject = stream;
|
||||||
|
document.body.appendChild(mediaEl);
|
||||||
|
// tile'a referans ekle ki _clearPeerTiles kapatabilsin
|
||||||
|
tile._audioEl = mediaEl;
|
||||||
|
|
||||||
|
const icon = document.createElement('div');
|
||||||
|
icon.className = 'mwse-stream-tile__audio-icon';
|
||||||
|
icon.textContent = 'graphic_eq';
|
||||||
|
tile.appendChild(icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt bilgi çubuğu: etiket + ses kıs/aç + kapat
|
||||||
|
const info = document.createElement('div');
|
||||||
|
info.className = 'mwse-stream-tile__info';
|
||||||
|
|
||||||
|
const lbl = document.createElement('span');
|
||||||
|
lbl.className = 'mwse-stream-tile__label';
|
||||||
|
lbl.textContent = isVideo ? 'Video' : 'Ses';
|
||||||
|
|
||||||
|
const peerEl = document.createElement('span');
|
||||||
|
peerEl.className = 'mwse-stream-tile__peer';
|
||||||
|
peerEl.textContent = `← ${peerLabel}`;
|
||||||
|
|
||||||
|
// Ses kısma butonu
|
||||||
|
const muteBtn = document.createElement('span');
|
||||||
|
muteBtn.className = 'mwse-stream-tile__mute';
|
||||||
|
muteBtn.textContent = 'volume_up';
|
||||||
|
muteBtn.title = 'Sesi kıs / aç';
|
||||||
|
let muted = false;
|
||||||
|
muteBtn.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
muted = !muted;
|
||||||
|
mediaEl.muted = muted;
|
||||||
|
muteBtn.textContent = muted ? 'volume_off' : 'volume_up';
|
||||||
|
muteBtn.classList.toggle('mwse-stream-tile__mute--off', muted);
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeBtn = document.createElement('span');
|
||||||
|
closeBtn.className = 'mwse-stream-tile__close';
|
||||||
|
closeBtn.textContent = 'close';
|
||||||
|
closeBtn.title = 'Tile kapat';
|
||||||
|
closeBtn.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!isVideo) { mediaEl.srcObject = null; mediaEl.remove(); }
|
||||||
|
tile.remove();
|
||||||
|
this._updatePanelVisibility();
|
||||||
|
});
|
||||||
|
|
||||||
|
info.append(lbl, peerEl, muteBtn, closeBtn);
|
||||||
|
tile.appendChild(info);
|
||||||
|
tile.dataset.peerId = peerId;
|
||||||
|
this._remoteGrid.appendChild(tile);
|
||||||
|
this._updatePanelVisibility();
|
||||||
|
|
||||||
|
// Track kapandığında tile'ı kaldır
|
||||||
|
track.addEventListener('ended', () => {
|
||||||
|
if (!isVideo) { mediaEl.srcObject = null; mediaEl.remove(); }
|
||||||
|
tile.remove();
|
||||||
|
this._updatePanelVisibility();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_makeTile(label, peerInfo, stream, isVideo, muted, onClose) {
|
||||||
|
const tile = document.createElement('div');
|
||||||
|
tile.className = `mwse-stream-tile${isVideo ? '' : ' mwse-stream-tile--audio'}`;
|
||||||
|
|
||||||
|
if (isVideo) {
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.autoplay = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
video.muted = muted;
|
||||||
|
video.srcObject = stream;
|
||||||
|
video.className = 'mwse-stream-tile__video';
|
||||||
|
tile.appendChild(video);
|
||||||
|
} else {
|
||||||
|
const icon = document.createElement('div');
|
||||||
|
icon.className = 'mwse-stream-tile__audio-icon';
|
||||||
|
icon.textContent = '♪';
|
||||||
|
tile.appendChild(icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = document.createElement('div');
|
||||||
|
info.className = 'mwse-stream-tile__info';
|
||||||
|
|
||||||
|
const lbl = document.createElement('span');
|
||||||
|
lbl.className = 'mwse-stream-tile__label';
|
||||||
|
lbl.textContent = label;
|
||||||
|
|
||||||
|
const peer = document.createElement('span');
|
||||||
|
peer.className = 'mwse-stream-tile__peer';
|
||||||
|
peer.textContent = peerInfo;
|
||||||
|
|
||||||
|
const closeBtn = document.createElement('span');
|
||||||
|
closeBtn.className = 'mwse-stream-tile__close';
|
||||||
|
closeBtn.textContent = '✕';
|
||||||
|
closeBtn.title = 'Kapat';
|
||||||
|
closeBtn.addEventListener('click', e => { e.stopPropagation(); onClose(); });
|
||||||
|
|
||||||
|
info.append(lbl, peer, closeBtn);
|
||||||
|
tile.appendChild(info);
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updatePanelVisibility() {
|
||||||
|
const hasLocal = this._localGrid.childElementCount > 0;
|
||||||
|
const hasRemote = this._remoteGrid.childElementCount > 0;
|
||||||
|
this._streamsPanel.style.display = (hasLocal || hasRemote) ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gelen istek bildirim alanı ────────────────────────────────────────────
|
||||||
|
|
||||||
|
_buildNotifArea() {
|
||||||
|
this._notifArea = document.createElement('div');
|
||||||
|
this._notifArea.className = 'mwse-notif-area';
|
||||||
|
const toolbar = this._el.querySelector('.mwse-studio__toolbar');
|
||||||
|
toolbar.insertAdjacentElement('afterend', this._notifArea);
|
||||||
|
}
|
||||||
|
|
||||||
|
_showPairRequest(peer) {
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'mwse-notif-bar';
|
||||||
|
|
||||||
|
const msg = document.createElement('div');
|
||||||
|
msg.className = 'mwse-notif-bar__msg';
|
||||||
|
msg.innerHTML = `<span class="mwse-notif-bar__dot">●</span>`
|
||||||
|
+ ` <code>${peer.socketId}</code> bağlanmak istiyor`;
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'mwse-notif-bar__actions';
|
||||||
|
|
||||||
|
const rejectBtn = document.createElement('button');
|
||||||
|
rejectBtn.className = 'mwse-btn mwse-btn--danger';
|
||||||
|
rejectBtn.textContent = 'Reddet';
|
||||||
|
rejectBtn.addEventListener('click', async () => {
|
||||||
|
await peer.rejectPair().catch(() => {});
|
||||||
|
bar.remove();
|
||||||
|
this._setStatus('', `${peer.socketId.slice(-8)} reddedildi`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const acceptBtn = document.createElement('button');
|
||||||
|
acceptBtn.className = 'mwse-btn mwse-btn--primary';
|
||||||
|
acceptBtn.textContent = 'Kabul Et';
|
||||||
|
acceptBtn.addEventListener('click', async () => {
|
||||||
|
acceptBtn.textContent = '…';
|
||||||
|
acceptBtn.disabled = true;
|
||||||
|
const ok = await peer.acceptPair().catch(() => false);
|
||||||
|
bar.remove();
|
||||||
|
if (ok) {
|
||||||
|
this._watchIncoming(peer);
|
||||||
|
if (this.mwse.virtualPressure.APIPAddress) {
|
||||||
|
this.mwse.me.info.set('ip', this.mwse.virtualPressure.APIPAddress);
|
||||||
|
}
|
||||||
|
// Eşler kolonunu aç ve anında doldur
|
||||||
|
if (this._view.depth < 2) this._view.popTo(1);
|
||||||
|
if (!this._peersCol) this._pushPeersColumn();
|
||||||
|
else this._rebuildPeerItems();
|
||||||
|
this._setStatus('online', `${this._peerLabel(peer)} kabul edildi`);
|
||||||
|
} else {
|
||||||
|
this._setStatus('error', 'Eşleşme kurulamadı');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
actions.append(rejectBtn, acceptBtn);
|
||||||
|
bar.append(msg, actions);
|
||||||
|
this._notifArea.appendChild(bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kök kolon ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_pushRootColumn() {
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
icon: 'people', label: 'Eşler',
|
||||||
|
meta: () => { const n = this.mwse.pairs.size; return n ? `${n} eşleşme` : 'Henüz yok'; },
|
||||||
|
onSelect: () => { this._view.popTo(1); this._pushPeersColumn(); }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'meeting_room', label: 'Odalar',
|
||||||
|
meta: () => { const n = this.mwse.rooms.size; return n ? `${n} oda` : 'Oda yok'; },
|
||||||
|
onSelect: () => { this._view.popTo(1); this._pushRoomsColumn(); }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'videocam', label: 'Kameralar',
|
||||||
|
meta: () => `${this._devices.cameras.length} kamera`,
|
||||||
|
onSelect: () => { this._view.popTo(1); this._pushDevicesColumn(null, 'video'); }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'mic', label: 'Mikrofonlar',
|
||||||
|
meta: () => `${this._devices.microphones.length} mikrofon`,
|
||||||
|
onSelect: () => { this._view.popTo(1); this._pushDevicesColumn(null, 'audio'); }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
this._view.pushColumn('Studio', items, { searchable: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Eşler kolonu ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_pushPeersColumn() {
|
||||||
|
const pairs = [...this.mwse.pairs.values()];
|
||||||
|
const items = pairs.map(peer => ({
|
||||||
|
icon: peer.rtc?.active ? 'sensors' : 'person',
|
||||||
|
label: this._peerLabel(peer),
|
||||||
|
meta: () => this._peerMeta(peer),
|
||||||
|
onSelect: () => { this._view.popTo(2); this._pushPeerColumn(peer); }
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
items.push({
|
||||||
|
icon: 'person_off', label: 'Henüz eş yok',
|
||||||
|
meta: '"ID ile ara" butonunu kullan', hasChildren: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this._peersCol = this._view.pushColumn('Eşler', items);
|
||||||
|
const col = this._peersCol;
|
||||||
|
col.addAction('<span class="mi mi-sm">person_search</span> ID ile ara', '', () => {
|
||||||
|
this._showModal({
|
||||||
|
title: 'Eşe bağlan',
|
||||||
|
fields: [{ key: 'id', label: 'Socket ID', placeholder: 'xxxxxxxx-xxxx-…' }],
|
||||||
|
confirm: 'Bağlan',
|
||||||
|
onConfirm: ({ id }) => {
|
||||||
|
if (!id) return;
|
||||||
|
this.mwse.peer(id).requestPair()
|
||||||
|
.then(ok => ok
|
||||||
|
? this._setStatus('', `${id.slice(-8)}'e istek gönderildi`)
|
||||||
|
: this._setStatus('error', 'İstek gönderilemedi'))
|
||||||
|
.catch(e => this._setStatus('error', e.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_peerLabel(peer) {
|
||||||
|
return peer.info?.info?.ip || peer.socketId.slice(-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eşler kolonunu mevcut mwse.pairs verisiyle canlı olarak günceller.
|
||||||
|
// Kolon henüz açılmamışsa no-op.
|
||||||
|
_rebuildPeerItems() {
|
||||||
|
if (!this._peersCol) return;
|
||||||
|
const pairs = [...this.mwse.pairs.values()];
|
||||||
|
const items = pairs.map(peer => ({
|
||||||
|
icon: peer.rtc?.active ? 'sensors' : 'person',
|
||||||
|
label: this._peerLabel(peer),
|
||||||
|
meta: () => this._peerMeta(peer),
|
||||||
|
onSelect: () => { this._view.popTo(2); this._pushPeerColumn(peer); }
|
||||||
|
}));
|
||||||
|
if (!items.length) {
|
||||||
|
items.push({
|
||||||
|
icon: 'person_off', label: 'Henüz eş yok',
|
||||||
|
meta: '"ID ile ara" butonunu kullan', hasChildren: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this._peersCol.setItems(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
_peerMeta(peer) {
|
||||||
|
const streams = peer.rtc?._streams?.list() ?? [];
|
||||||
|
const id = peer.socketId.slice(-8);
|
||||||
|
if (streams.length) return `${id} · p2p · ${streams.length} akış`;
|
||||||
|
if (peer.rtc?.active) return `${id} · p2p`;
|
||||||
|
return `${id} · websocket`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Eş eylem kolonu ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_pushPeerColumn(peer) {
|
||||||
|
const streams = peer.rtc?._streams?.list() ?? [];
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ icon: 'videocam', label: 'Video + Ses', meta: 'Kamera ve mikrofon', onSelect: () => this._call(peer, 'cam+mic') },
|
||||||
|
{ icon: 'mic', label: 'Sesli Ara', meta: 'Yalnızca mikrofon', onSelect: () => this._call(peer, 'mic') },
|
||||||
|
{ icon: 'screen_share', label: 'Ekran Paylaş', meta: 'getDisplayMedia', onSelect: () => this._call(peer, 'screen') },
|
||||||
|
{ icon: 'switch_video', label: 'Kamera Seç', meta: `${this._devices.cameras.length} kamera`,
|
||||||
|
onSelect: () => { this._view.popTo(3); this._pushDevicesColumn(peer, 'video'); } },
|
||||||
|
{ icon: 'settings_voice', label: 'Mikrofon Seç', meta: `${this._devices.microphones.length} mikrofon`,
|
||||||
|
onSelect: () => { this._view.popTo(3); this._pushDevicesColumn(peer, 'audio'); } },
|
||||||
|
{ icon: 'upload_file', label: 'Dosya Gönder', meta: 'P2P DataChannel', hasChildren: false,
|
||||||
|
onSelect: () => this._sendFile(peer) }
|
||||||
|
];
|
||||||
|
|
||||||
|
if (streams.length) {
|
||||||
|
items.push({
|
||||||
|
icon: 'live_tv', label: `Aktif Akışlar (${streams.length})`,
|
||||||
|
meta: streams.map(s => s.label).join(' · '),
|
||||||
|
onSelect: () => { this._view.popTo(3); this._pushStreamsColumn(peer); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({ icon: 'link_off', label: 'Eşleşmeyi Bitir', meta: '', hasChildren: false,
|
||||||
|
onSelect: async () => {
|
||||||
|
await peer.endPair().catch(() => {});
|
||||||
|
this._view.popTo(1); this._pushPeersColumn();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._view.pushColumn(this._peerLabel(peer), items, { searchable: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Odalar kolonu ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_pushRoomsColumn() {
|
||||||
|
const items = [...this.mwse.rooms.entries()].map(([, room]) => ({
|
||||||
|
icon: 'meeting_room',
|
||||||
|
label: room.config?.name ?? room.roomId,
|
||||||
|
meta: () => `${room.peers.size} üye`,
|
||||||
|
onSelect: () => { this._view.popTo(2); this._pushRoomMembersColumn(room); }
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!items.length) items.push({ icon: 'block', label: 'Oda yok', meta: '"Oda Oluştur" butonunu kullan', hasChildren: false });
|
||||||
|
|
||||||
|
const col = this._view.pushColumn('Odalar', items);
|
||||||
|
col.addAction('<span class="mi mi-sm">add</span> Oda Oluştur', 'mwse-btn--primary', () => {
|
||||||
|
this._showModal({
|
||||||
|
title: 'Yeni Oda',
|
||||||
|
fields: [
|
||||||
|
{ key: 'name', label: 'Oda adı', placeholder: 'genel' },
|
||||||
|
{ key: 'desc', label: 'Açıklama (opsiyonel)', placeholder: 'Genel sohbet odası' },
|
||||||
|
{ key: 'pass', label: 'Şifre (opsiyonel)', placeholder: 'boş = şifresiz' }
|
||||||
|
],
|
||||||
|
confirm: 'Oluştur',
|
||||||
|
onConfirm: async ({ name, desc, pass }) => {
|
||||||
|
if (!name) return;
|
||||||
|
const room = this.mwse.room({
|
||||||
|
name, description: desc || name,
|
||||||
|
joinType: pass ? 'password' : 'free',
|
||||||
|
accessType: 'public', ifexistsJoin: true,
|
||||||
|
notifyActionJoined: true, notifyActionEjected: true, notifyActionInvite: false,
|
||||||
|
...(pass ? { password: pass } : {})
|
||||||
|
});
|
||||||
|
await room.createRoom();
|
||||||
|
this._setStatus('online', `"${name}" oluşturuldu`);
|
||||||
|
this._view.popTo(1); this._pushRoomsColumn();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_pushRoomMembersColumn(room) {
|
||||||
|
const myIP = this.mwse.virtualPressure.APIPAddress;
|
||||||
|
const items = [...room.peers.values()].map(peer => ({
|
||||||
|
icon: peer.selfSocket ? 'star' : 'person',
|
||||||
|
label: peer.selfSocket ? (myIP || 'Ben') : this._peerLabel(peer),
|
||||||
|
meta: () => this._peerMeta(peer),
|
||||||
|
onSelect: () => { this._view.popTo(3); this._pushPeerColumn(peer); }
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!items.length) items.push({ icon: '—', label: 'Üye yok', meta: '', hasChildren: false });
|
||||||
|
|
||||||
|
const col = this._view.pushColumn(room.config?.name ?? room.roomId, items);
|
||||||
|
col.addAction('<span class="mi mi-sm">exit_to_app</span> Çık', 'mwse-btn--danger', async () => {
|
||||||
|
await room.eject().catch(() => {});
|
||||||
|
this._view.popTo(1); this._pushRoomsColumn();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cihazlar kolonu ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_pushDevicesColumn(peer, kind) {
|
||||||
|
const list = kind === 'audio' ? this._devices.microphones : this._devices.cameras;
|
||||||
|
const title = kind === 'audio' ? 'Mikrofonlar' : 'Kameralar';
|
||||||
|
|
||||||
|
const items = list.map(dev => ({
|
||||||
|
icon: kind === 'audio' ? 'mic' : 'videocam',
|
||||||
|
label: dev.label || `Cihaz ${dev.deviceId.slice(-6)}`,
|
||||||
|
meta: dev.deviceId.slice(-8),
|
||||||
|
onSelect: async () => {
|
||||||
|
const constraints = kind === 'audio'
|
||||||
|
? { audio: { deviceId: { exact: dev.deviceId } } }
|
||||||
|
: { video: { deviceId: { exact: dev.deviceId } } };
|
||||||
|
const stream = await (kind === 'audio'
|
||||||
|
? MediaSources.microphone(constraints)
|
||||||
|
: MediaSources.camera(constraints)
|
||||||
|
).catch(e => { this._setStatus('error', e.message); return null; });
|
||||||
|
if (!stream) return;
|
||||||
|
|
||||||
|
if (peer) {
|
||||||
|
this._ensureRTC(peer);
|
||||||
|
const label = dev.label || dev.deviceId.slice(-6);
|
||||||
|
if (peer.rtc._streams?.has(label)) peer.rtc.removeStream(label);
|
||||||
|
peer.rtc.addStream(label, stream);
|
||||||
|
this._addLocalTile(label, stream, peer);
|
||||||
|
this._view.popTo(4); this._pushStreamsColumn(peer);
|
||||||
|
} else {
|
||||||
|
this._previewStream(stream, dev.label || title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!items.length) items.push({ icon: 'perm_media', label: 'Cihaz bulunamadı', meta: '"İzin İste" butonunu kullan', hasChildren: false });
|
||||||
|
|
||||||
|
const col = this._view.pushColumn(title, items);
|
||||||
|
col.addAction('<span class="mi mi-sm">lock_open</span> İzin İste', '', async () => {
|
||||||
|
await navigator.mediaDevices.getUserMedia(kind === 'audio' ? { audio: true } : { video: true }).catch(() => {});
|
||||||
|
await this._loadDevices();
|
||||||
|
this._view.popTo(this._view.depth - 1);
|
||||||
|
this._pushDevicesColumn(peer, kind);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Akışlar / Kalite kolonları ────────────────────────────────────────────
|
||||||
|
|
||||||
|
_pushStreamsColumn(peer) {
|
||||||
|
const srcs = peer.rtc?._streams?.list() ?? [];
|
||||||
|
const items = srcs.map(src => ({
|
||||||
|
icon: src.tracks[0]?.kind === 'video' ? 'live_tv' : 'graphic_eq',
|
||||||
|
label: src.label,
|
||||||
|
meta: src.tracks.map(t => `${t.kind}${t.enabled ? '' : ' (sessiz)'}`).join(' + '),
|
||||||
|
onSelect: () => { this._view.popTo(4); this._pushQualityColumn(peer, src.label, src); }
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!items.length) items.push({ icon: '—', label: 'Akış yok', meta: '', hasChildren: false });
|
||||||
|
this._view.pushColumn('Akışlar', items);
|
||||||
|
}
|
||||||
|
|
||||||
|
_pushQualityColumn(peer, label, src) {
|
||||||
|
const presets = [
|
||||||
|
{ icon: 'hd', label: 'Yüksek', meta: '1080p · 4 Mbps', params: { maxBitrate: 4_000_000, scaleResolutionDownBy: 1 } },
|
||||||
|
{ icon: 'sd', label: 'Orta', meta: '720p · 1.5 Mbps', params: { maxBitrate: 1_500_000, scaleResolutionDownBy: 1.5 } },
|
||||||
|
{ icon: 'signal_cellular_1_bar', label: 'Düşük', meta: '480p · 500 Kbps', params: { maxBitrate: 500_000, scaleResolutionDownBy: 2 } },
|
||||||
|
];
|
||||||
|
const items = presets.map(p => ({
|
||||||
|
icon: p.icon, label: p.label, meta: p.meta, hasChildren: false,
|
||||||
|
onSelect: () => peer.rtc?.setEncodings(label, 'video', p.params)
|
||||||
|
}));
|
||||||
|
for (const track of (src.tracks ?? [])) {
|
||||||
|
items.push({
|
||||||
|
icon: track.enabled
|
||||||
|
? (track.kind === 'video' ? 'videocam_off' : 'mic_off')
|
||||||
|
: (track.kind === 'video' ? 'videocam' : 'mic'),
|
||||||
|
label: `${track.kind === 'video' ? 'Video' : 'Ses'} ${track.enabled ? 'Sustur' : 'Aç'}`,
|
||||||
|
meta: '', hasChildren: false,
|
||||||
|
onSelect: () => { peer.rtc?.setEnabled(label, track.kind, !track.enabled); this._view.refresh(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
icon: 'stop_circle', label: 'Akışı Durdur', meta: '', hasChildren: false,
|
||||||
|
onSelect: () => { peer.rtc?.removeStream(label); this._view.popTo(3); this._pushStreamsColumn(peer); }
|
||||||
|
});
|
||||||
|
this._view.pushColumn('Kalite', items, { searchable: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WebRTC yardımcıları ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async _call(peer, type) {
|
||||||
|
this._ensureRTC(peer);
|
||||||
|
let stream;
|
||||||
|
try {
|
||||||
|
if (type === 'cam+mic') stream = await MediaSources.cameraAndMic();
|
||||||
|
else if (type === 'mic') stream = await MediaSources.microphone();
|
||||||
|
else if (type === 'screen') stream = await MediaSources.screen();
|
||||||
|
} catch (e) { this._setStatus('error', e.message); return; }
|
||||||
|
|
||||||
|
if (peer.rtc._streams?.has(type)) peer.rtc.removeStream(type);
|
||||||
|
peer.rtc.addStream(type, stream);
|
||||||
|
this._addLocalTile(type, stream, peer);
|
||||||
|
this._setStatus('online', `${type} → ${this._peerLabel(peer)}`);
|
||||||
|
this._view.popTo(3);
|
||||||
|
this._pushStreamsColumn(peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
_sendFile(peer) {
|
||||||
|
this._ensureRTC(peer);
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.addEventListener('change', async () => {
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
this._setStatus('', `${file.name} gönderiliyor…`);
|
||||||
|
peer.rtc.files.on('progress', ({ sent, total }) =>
|
||||||
|
this._setStatus('', `${file.name} %${Math.round(sent / total * 100)}`)
|
||||||
|
);
|
||||||
|
await peer.rtc.sendFile(file).catch(e => this._setStatus('error', e.message));
|
||||||
|
this._setStatus('online', `${file.name} gönderildi`);
|
||||||
|
});
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensureRTC(peer) {
|
||||||
|
if (peer.rtc?._pc) return;
|
||||||
|
peer.rtc.connect({ polite: this.mwse.me.socketId < peer.socketId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bir peer'a ait tüm tile'ları temizle (pair bitti veya disconnect)
|
||||||
|
_clearPeerTiles(peerId) {
|
||||||
|
for (const grid of [this._localGrid, this._remoteGrid]) {
|
||||||
|
if (!grid) continue;
|
||||||
|
// dataset.peerId → attribute adı: data-peer-id
|
||||||
|
for (const tile of [...grid.querySelectorAll(`[data-peer-id="${CSS.escape(peerId)}"]`)]) {
|
||||||
|
// Gizli <audio> elemanlarını da kapat
|
||||||
|
const audio = tile._audioEl;
|
||||||
|
if (audio) { audio.srcObject = null; audio.remove(); }
|
||||||
|
tile.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._updatePanelVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gelen track'leri izle → panel'e ekle
|
||||||
|
_watchIncoming(peer) {
|
||||||
|
// RTC başlatılmazsa gelen :rtcpack: sinyalleri receive() içinde _neg=null
|
||||||
|
// nedeniyle sessizce düşer, track olayı hiç ateşlenmez.
|
||||||
|
this._ensureRTC(peer);
|
||||||
|
|
||||||
|
peer.rtc.on('track', (track, streams) => {
|
||||||
|
this._addRemoteTile(track, streams, peer);
|
||||||
|
this._setStatus('online', `← ${this._peerLabel(peer)} ${track.kind}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// RTC kapandığında (endPair, peer/disconnect) tile'ları temizle
|
||||||
|
peer.rtc.on('disconnected', () => {
|
||||||
|
this._clearPeerTiles(peer.socketId);
|
||||||
|
this._rebuildPeerItems();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_previewStream(stream, label) {
|
||||||
|
document.getElementById('mwse-preview')?.remove();
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.id = 'mwse-preview';
|
||||||
|
Object.assign(wrap.style, { position:'fixed', bottom:'12px', right:'12px', width:'220px',
|
||||||
|
background:'#111', border:'1px solid #333', borderRadius:'6px', overflow:'hidden', zIndex:'9000' });
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
Object.assign(bar.style, { padding:'4px 8px', fontSize:'11px', color:'#aaa', background:'#1a1a1a',
|
||||||
|
display:'flex', justifyContent:'space-between', alignItems:'center' });
|
||||||
|
const close = document.createElement('span');
|
||||||
|
close.textContent = '✕'; close.style.cursor = 'pointer';
|
||||||
|
close.addEventListener('click', () => { stream.getTracks().forEach(t => t.stop()); wrap.remove(); });
|
||||||
|
bar.append(document.createTextNode(label), close);
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.autoplay = true; video.muted = true; video.playsInline = true;
|
||||||
|
video.srcObject = stream; video.style.cssText = 'width:100%;display:block;background:#000';
|
||||||
|
wrap.append(bar, video);
|
||||||
|
document.body.appendChild(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Modal ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_showModal({ title, fields = [], confirm = 'Tamam', onConfirm }) {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'mwse-modal-overlay';
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'mwse-modal';
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'mwse-modal__header';
|
||||||
|
const titleEl = document.createElement('span');
|
||||||
|
titleEl.textContent = title;
|
||||||
|
const closeX = document.createElement('span');
|
||||||
|
closeX.className = 'mwse-modal__close'; closeX.textContent = '✕';
|
||||||
|
closeX.addEventListener('click', () => overlay.remove());
|
||||||
|
header.append(titleEl, closeX);
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'mwse-modal__body';
|
||||||
|
const inputs = {};
|
||||||
|
for (const f of fields) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'mwse-modal__field';
|
||||||
|
const lbl = document.createElement('label'); lbl.textContent = f.label;
|
||||||
|
const inp = document.createElement('input');
|
||||||
|
inp.className = 'mwse-modal__input'; inp.placeholder = f.placeholder ?? ''; inp.type = f.type ?? 'text';
|
||||||
|
inputs[f.key] = inp;
|
||||||
|
inp.addEventListener('keydown', e => { if (e.key==='Enter') confirmBtn.click(); if (e.key==='Escape') overlay.remove(); });
|
||||||
|
wrap.append(lbl, inp); body.appendChild(wrap);
|
||||||
|
}
|
||||||
|
const footer = document.createElement('div');
|
||||||
|
footer.className = 'mwse-modal__footer';
|
||||||
|
const cancelBtn = document.createElement('button');
|
||||||
|
cancelBtn.className = 'mwse-btn'; cancelBtn.textContent = 'İptal';
|
||||||
|
cancelBtn.addEventListener('click', () => overlay.remove());
|
||||||
|
const confirmBtn = document.createElement('button');
|
||||||
|
confirmBtn.className = 'mwse-btn mwse-btn--primary'; confirmBtn.textContent = confirm;
|
||||||
|
confirmBtn.addEventListener('click', () => {
|
||||||
|
const values = {};
|
||||||
|
for (const [k, el] of Object.entries(inputs)) values[k] = el.value.trim();
|
||||||
|
overlay.remove(); onConfirm(values);
|
||||||
|
});
|
||||||
|
footer.append(cancelBtn, confirmBtn);
|
||||||
|
modal.append(header, body, footer);
|
||||||
|
overlay.appendChild(modal);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
|
||||||
|
setTimeout(() => Object.values(inputs)[0]?.focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Yardımcılar ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async _loadDevices() {
|
||||||
|
try { this._devices = await MediaSources.devices(); }
|
||||||
|
catch (_) { this._devices = { cameras: [], microphones: [] }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
_setStatus(cls, text) {
|
||||||
|
if (!this._statusEl) return;
|
||||||
|
this._statusEl.className = ['mwse-studio__status', cls ? `mwse-studio__status--${cls}` : ''].join(' ').trim();
|
||||||
|
this._statusEl.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
_injectStyle() {
|
||||||
|
if (this._styleOK) return;
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'stylesheet'; link.href = '/studio/style.css';
|
||||||
|
document.head.appendChild(link); this._styleOK = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import MWSE from '/sdk/index.js';
|
||||||
|
import Studio from '/studio/Studio.js';
|
||||||
|
|
||||||
|
const loadingEl = document.getElementById('loading');
|
||||||
|
const loadingMsg = document.getElementById('loading-msg');
|
||||||
|
const appEl = document.getElementById('app');
|
||||||
|
|
||||||
|
const mwse = new MWSE(); // endpoint: otomatik — aynı sunucu
|
||||||
|
const studio = new Studio(mwse, appEl);
|
||||||
|
|
||||||
|
mwse.on('scope', async () => {
|
||||||
|
loadingEl.classList.add('hidden');
|
||||||
|
await studio.mount();
|
||||||
|
});
|
||||||
|
|
||||||
|
mwse.on('close', () => {
|
||||||
|
loadingMsg.textContent = 'Bağlantı kesildi — yeniden bağlanılıyor…';
|
||||||
|
loadingEl.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
mwse.on('error', err => {
|
||||||
|
// Versiyon uyuşmazlığı veya hello timeout gibi hatalar burada görünür
|
||||||
|
loadingMsg.textContent = `Hata: ${err.message}`;
|
||||||
|
loadingEl.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="tr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>MWSE Studio</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body { width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; }
|
||||||
|
#app { width: 100%; height: 100%; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
/* Loading overlay */
|
||||||
|
#loading {
|
||||||
|
position: fixed; inset: 0; background: #1a1a1a;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
color: #888; font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
font-size: 13px; gap: 14px; z-index: 9999;
|
||||||
|
}
|
||||||
|
#loading .logo {
|
||||||
|
font-size: 22px; font-weight: 700; color: #fff;
|
||||||
|
letter-spacing: .12em; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
#loading .logo span { color: #0078d4; }
|
||||||
|
#loading .spinner {
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
border: 2px solid #2a2a2a;
|
||||||
|
border-top-color: #0078d4;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin .7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
#loading.hidden { display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="loading">
|
||||||
|
<div class="logo">MWSE <span>Studio</span></div>
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div id="loading-msg">Sunucuya bağlanıyor…</div>
|
||||||
|
</div>
|
||||||
|
<div id="app"></div>
|
||||||
|
|
||||||
|
<script type="module" src="/studio/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,409 @@
|
||||||
|
/* MWSE Studio — desktop-first Miller-column UI */
|
||||||
|
@import url('https://fonts.googleapis.com/icon?family=Material+Icons+Round');
|
||||||
|
|
||||||
|
.mwse-studio {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #d4d4d4;
|
||||||
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Material icon helper: inline ikon */
|
||||||
|
.mi {
|
||||||
|
font-family: 'Material Icons Round';
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
vertical-align: middle;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.mi-sm { font-size: 14px; }
|
||||||
|
.mi-lg { font-size: 20px; }
|
||||||
|
|
||||||
|
/* ── Araç çubuğu ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mwse-studio__toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 14px;
|
||||||
|
height: 46px;
|
||||||
|
background: #0d0d0d;
|
||||||
|
border-bottom: 1px solid #2a2a2a;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mwse-studio__title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 17px;
|
||||||
|
letter-spacing: .03em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mwse-studio__status {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.mwse-studio__status--online { color: #4caf50; }
|
||||||
|
.mwse-studio__status--error { color: #f44336; }
|
||||||
|
|
||||||
|
/* Saat */
|
||||||
|
.mwse-studio__clock {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: 'Consolas', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ana alan (kolonlar + akış paneli yan yana) ──────────────────────── */
|
||||||
|
|
||||||
|
.mwse-studio__main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Kolon kaydırma alanı ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mwse-studio__columns {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex: 1;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #444 #1a1a1a;
|
||||||
|
}
|
||||||
|
.mwse-studio__columns::-webkit-scrollbar { height: 4px; }
|
||||||
|
.mwse-studio__columns::-webkit-scrollbar-track { background: #1a1a1a; }
|
||||||
|
.mwse-studio__columns::-webkit-scrollbar-thumb { background: #444; border-radius: 2px; }
|
||||||
|
|
||||||
|
/* ── Akış monitör paneli ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mwse-streams-panel {
|
||||||
|
width: 260px;
|
||||||
|
min-width: 260px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #141414;
|
||||||
|
border-left: 1px solid #2a2a2a;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #333 transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mwse-streams-section {
|
||||||
|
padding: 10px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mwse-streams-section__title {
|
||||||
|
padding: 0 12px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mwse-streams-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tek akış tile'ı */
|
||||||
|
.mwse-stream-tile {
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1e1e1e;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mwse-stream-tile__video {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
display: block;
|
||||||
|
background: #000;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ses tile'ı (video yok) */
|
||||||
|
.mwse-stream-tile--audio .mwse-stream-tile__audio-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 60px;
|
||||||
|
font-family: 'Material Icons Round';
|
||||||
|
font-size: 28px;
|
||||||
|
color: #0078d4;
|
||||||
|
background: #0d1e2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mwse-stream-tile__info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: rgba(0,0,0,.5);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mwse-stream-tile__label { color: #ccc; font-weight: 600; flex: 1; }
|
||||||
|
.mwse-stream-tile__peer { color: #555; }
|
||||||
|
.mwse-stream-tile__mute {
|
||||||
|
font-family: 'Material Icons Round';
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #4caf50;
|
||||||
|
padding: 1px 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.mwse-stream-tile__mute:hover { background: #2a2a2a; }
|
||||||
|
.mwse-stream-tile__mute--off { color: #f44336; }
|
||||||
|
|
||||||
|
.mwse-stream-tile__close {
|
||||||
|
font-family: 'Material Icons Round';
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer; color: #444;
|
||||||
|
padding: 1px 3px; border-radius: 2px;
|
||||||
|
flex-shrink: 0; line-height: 1;
|
||||||
|
}
|
||||||
|
.mwse-stream-tile__close:hover { background: #333; color: #ccc; }
|
||||||
|
|
||||||
|
/* ── Tek kolon ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mwse-col {
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 260px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid #2e2e2e;
|
||||||
|
background: #1e1e1e;
|
||||||
|
}
|
||||||
|
.mwse-col--active { background: #222; }
|
||||||
|
|
||||||
|
.mwse-col__header {
|
||||||
|
padding: 8px 12px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #666;
|
||||||
|
border-bottom: 1px solid #2a2a2a;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mwse-col__search {
|
||||||
|
margin: 6px 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #383838;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #d4d4d4;
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.mwse-col__search:focus { border-color: #0078d4; }
|
||||||
|
|
||||||
|
.mwse-col__list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #333 transparent;
|
||||||
|
}
|
||||||
|
.mwse-col__list::-webkit-scrollbar { width: 4px; }
|
||||||
|
.mwse-col__list::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }
|
||||||
|
|
||||||
|
/* ── Liste öğesi ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mwse-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
transition: background 70ms;
|
||||||
|
}
|
||||||
|
.mwse-item:hover { background: #2a2a2a; }
|
||||||
|
.mwse-item--active {
|
||||||
|
background: #0d3a5a !important;
|
||||||
|
border-left-color: #0078d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mwse-item__icon {
|
||||||
|
font-family: 'Material Icons Round';
|
||||||
|
font-size: 18px;
|
||||||
|
color: #555;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.mwse-item--active .mwse-item__icon { color: #60cdff; }
|
||||||
|
|
||||||
|
.mwse-item__body { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.mwse-item__label {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: #d0d0d0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.mwse-item--active .mwse-item__label { color: #fff; }
|
||||||
|
|
||||||
|
.mwse-item__meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
margin-top: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.mwse-item--active .mwse-item__meta { color: #4d9fce; }
|
||||||
|
|
||||||
|
.mwse-item__arrow {
|
||||||
|
font-family: 'Material Icons Round';
|
||||||
|
font-size: 14px;
|
||||||
|
color: #3a3a3a;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.mwse-item--active .mwse-item__arrow { color: #60cdff; }
|
||||||
|
|
||||||
|
/* ── Alt buton alanı ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mwse-col__actions {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-top: 1px solid #2a2a2a;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mwse-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 5px 8px;
|
||||||
|
background: #252525;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #bbb;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mwse-btn:hover { background: #2e2e2e; color: #fff; border-color: #444; }
|
||||||
|
.mwse-btn--primary { background: #0d47a1; border-color: #1565c0; color: #fff; }
|
||||||
|
.mwse-btn--primary:hover { background: #1565c0; }
|
||||||
|
.mwse-btn--danger { background: #3a1010; border-color: #6a2020; color: #f99; }
|
||||||
|
.mwse-btn--danger:hover { background: #6a2020; }
|
||||||
|
|
||||||
|
/* ── Gelen istek bildirimi ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mwse-notif-area { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.mwse-notif-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: #0e1f0e;
|
||||||
|
border-bottom: 1px solid #1e3a1e;
|
||||||
|
}
|
||||||
|
.mwse-notif-bar__msg { flex: 1; font-size: 12px; color: #a8c8a8; }
|
||||||
|
.mwse-notif-bar__msg code {
|
||||||
|
font-family: 'Consolas', monospace; font-size: 11px;
|
||||||
|
background: #152515; padding: 1px 5px; border-radius: 3px; color: #7cac7c;
|
||||||
|
}
|
||||||
|
.mwse-notif-bar__dot { color: #4caf50; }
|
||||||
|
.mwse-notif-bar__actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||||
|
.mwse-notif-bar__actions .mwse-btn { padding: 4px 14px; font-size: 11px; }
|
||||||
|
|
||||||
|
/* ── ID kartı ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mwse-id-card {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: #1e1e1e; border: 1px solid #333; border-radius: 5px;
|
||||||
|
cursor: pointer; transition: border-color 120ms;
|
||||||
|
}
|
||||||
|
.mwse-id-card:hover { border-color: #0078d4; }
|
||||||
|
|
||||||
|
.mwse-id-card__ip {
|
||||||
|
font-family: 'Consolas', monospace; font-size: 13px;
|
||||||
|
font-weight: 700; color: #60cdff; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mwse-id-card__ip:empty + .mwse-id-card__value { margin-left: 0; }
|
||||||
|
|
||||||
|
.mwse-id-card__value {
|
||||||
|
font-family: 'Consolas', monospace; font-size: 10px;
|
||||||
|
color: #555; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mwse-id-card__copy { font-size: 14px; color: #444; }
|
||||||
|
.mwse-id-card:hover .mwse-id-card__copy { color: #0078d4; }
|
||||||
|
.mwse-id-card--copied { border-color: #4caf50 !important; }
|
||||||
|
.mwse-id-card--copied .mwse-id-card__ip,
|
||||||
|
.mwse-id-card--copied .mwse-id-card__copy { color: #4caf50; }
|
||||||
|
|
||||||
|
/* ── Modal ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mwse-modal-overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,.7);
|
||||||
|
display: flex; align-items: center; justify-content: center; z-index: 9999;
|
||||||
|
}
|
||||||
|
.mwse-modal {
|
||||||
|
background: #1e1e1e; border: 1px solid #3a3a3a; border-radius: 6px;
|
||||||
|
width: 380px; max-width: calc(100vw - 32px);
|
||||||
|
box-shadow: 0 12px 40px rgba(0,0,0,.6);
|
||||||
|
}
|
||||||
|
.mwse-modal__header {
|
||||||
|
padding: 12px 16px 10px; border-bottom: 1px solid #2a2a2a;
|
||||||
|
font-weight: 600; font-size: 13px; color: #e0e0e0;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
}
|
||||||
|
.mwse-modal__close { cursor: pointer; color: #555; font-size: 16px; padding: 2px 4px; border-radius: 3px; }
|
||||||
|
.mwse-modal__close:hover { background: #2a2a2a; color: #ccc; }
|
||||||
|
.mwse-modal__body { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.mwse-modal__field { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.mwse-modal__field label { font-size: 11px; color: #777; }
|
||||||
|
.mwse-modal__input {
|
||||||
|
width: 100%; padding: 7px 10px; background: #2a2a2a;
|
||||||
|
border: 1px solid #3a3a3a; border-radius: 4px; color: #d4d4d4;
|
||||||
|
font-size: 13px; outline: none; box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.mwse-modal__input:focus { border-color: #0078d4; }
|
||||||
|
.mwse-modal__footer { padding: 10px 16px 14px; display: flex; gap: 8px; justify-content: flex-end; }
|
||||||
|
.mwse-modal__footer .mwse-btn { flex: none; padding: 6px 20px; font-size: 12px; }
|
||||||
1672
script/index.js
1672
script/index.js
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,105 @@
|
||||||
|
// WebSocket lifecycle management.
|
||||||
|
// import.meta.url is used for the 'auto' endpoint mode so the SDK always
|
||||||
|
// connects back to the same server it was downloaded from.
|
||||||
|
|
||||||
|
export class Connection {
|
||||||
|
constructor(mwse, options) {
|
||||||
|
this.mwse = mwse;
|
||||||
|
this.connected = false;
|
||||||
|
this.autoPair = false;
|
||||||
|
this.autoReconnect = true;
|
||||||
|
this.autoReconnectTimeout = 3000;
|
||||||
|
this.autoReconnectTimer = undefined;
|
||||||
|
|
||||||
|
this._activeCallbacks = [];
|
||||||
|
this._passiveCallbacks = [];
|
||||||
|
this._packCallbacks = [];
|
||||||
|
|
||||||
|
if (options.endpoint === 'auto') {
|
||||||
|
// In ES modules document.currentScript is null; use import.meta.url
|
||||||
|
// instead — it resolves to the URL the SDK was actually loaded from.
|
||||||
|
const scriptURL = new URL(import.meta.url);
|
||||||
|
const isSecure = scriptURL.protocol === 'https:';
|
||||||
|
scriptURL.protocol = isSecure ? 'wss:' : 'ws:';
|
||||||
|
// Strip /index.js (or any filename) so we connect to the server root.
|
||||||
|
scriptURL.pathname = scriptURL.pathname.replace(/\/[^/]+$/, '/');
|
||||||
|
this.endpoint = scriptURL;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
this.endpoint = new URL(options.endpoint);
|
||||||
|
} catch {
|
||||||
|
throw new Error('MWSE: endpoint is required and must be a valid URL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof options.autoReconnect === 'boolean') {
|
||||||
|
this.autoReconnect = options.autoReconnect;
|
||||||
|
} else if (options.autoReconnect) {
|
||||||
|
this.autoReconnect = true;
|
||||||
|
this.autoReconnectTimeout = options.autoReconnect.timeout;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
if (this.autoReconnectTimer) clearTimeout(this.autoReconnectTimer);
|
||||||
|
this.ws = new WebSocket(this.endpoint.href);
|
||||||
|
this._attachEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
// Prevent auto-reconnect when the caller explicitly closes.
|
||||||
|
this.autoReconnect = false;
|
||||||
|
this.ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
_attachEvents() {
|
||||||
|
this.ws.addEventListener('open', () => this._onOpen());
|
||||||
|
this.ws.addEventListener('close', () => this._onClose());
|
||||||
|
this.ws.addEventListener('error', () => this._onError());
|
||||||
|
this.ws.addEventListener('message', ({ data }) => this._onMessage(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
_onOpen() {
|
||||||
|
this.connected = true;
|
||||||
|
for (const cb of this._activeCallbacks) cb();
|
||||||
|
}
|
||||||
|
|
||||||
|
_onClose() {
|
||||||
|
for (const cb of this._passiveCallbacks) cb();
|
||||||
|
this.connected = false;
|
||||||
|
if (this.autoReconnect) {
|
||||||
|
this.autoReconnectTimer = setTimeout(
|
||||||
|
() => this.connect(),
|
||||||
|
this.autoReconnectTimeout
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onError() {
|
||||||
|
this.connected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_onMessage(data) {
|
||||||
|
// Pass raw wire data to WSTSProtocol; codec.decode() handles parsing.
|
||||||
|
// Pre-parsing here would hand an object to codec.decode which only accepts string/ArrayBuffer.
|
||||||
|
if (typeof data === 'string' || data instanceof ArrayBuffer) {
|
||||||
|
for (const cb of this._packCallbacks) cb(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onRecaivePack(fn) { this._packCallbacks.push(fn); }
|
||||||
|
|
||||||
|
onActive(fn) {
|
||||||
|
if (this.connected) fn();
|
||||||
|
else this._activeCallbacks.push(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPassive(fn) {
|
||||||
|
if (!this.connected) fn();
|
||||||
|
else this._passiveCallbacks.push(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
tranferToServer(data) {
|
||||||
|
if (this.connected) this.ws.send(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
// EventPool: pending-request registry and signal fan-out.
|
||||||
|
//
|
||||||
|
// Two distinct paths (see issue #33 for why the split matters):
|
||||||
|
// request() — sends a packet AND registers a waiter for the correlated reply.
|
||||||
|
// Use ONLY for handlers that return a reply (my/socketid, joinroom, …).
|
||||||
|
// only() — sends a packet with NO registered waiter (fire-and-forget / WOM).
|
||||||
|
// Use for pack/to, pack/room without handshake, etc. — anything the
|
||||||
|
// engine answers nil to, so there is no reply to wait for.
|
||||||
|
// Confusing the two leaves orphaned Promises or swallowed replies (#33).
|
||||||
|
|
||||||
|
export default class EventPool {
|
||||||
|
constructor(mwse) {
|
||||||
|
this.wsts = mwse;
|
||||||
|
this.events = new Map(); // id → [resolve, reject]
|
||||||
|
this.signals = new Map(); // signalName → [callback, …]
|
||||||
|
this.count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// request: sends msg and returns a Promise resolved by the server's reply.
|
||||||
|
request(msg) {
|
||||||
|
return new Promise((ok, rej) => {
|
||||||
|
const id = ++this.count;
|
||||||
|
this.events.set(id, [ok, rej]);
|
||||||
|
this.wsts.WSTSProtocol.SendRequest(msg, id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// only: sends msg without registering a waiter (WOM / fire-and-forget).
|
||||||
|
only(msg) {
|
||||||
|
this.wsts.WSTSProtocol.SendOnly(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// stream: sends msg and calls callback for every 'C'ontinue reply.
|
||||||
|
stream(msg, callback) {
|
||||||
|
const id = ++this.count;
|
||||||
|
this.wsts.WSTSProtocol.StartStream(msg, id);
|
||||||
|
this.events.set(id, [callback, () => {}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// signal: registers a listener for a named server-initiated signal.
|
||||||
|
signal(event, callback) {
|
||||||
|
const existing = this.signals.get(event);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(callback);
|
||||||
|
} else {
|
||||||
|
this.signals.set(event, [callback]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Minimal event emitter used as a base class throughout the SDK.
|
||||||
|
// Named MWSEEventTarget to avoid collision with the browser's built-in EventTarget.
|
||||||
|
export default class MWSEEventTarget {
|
||||||
|
constructor() {
|
||||||
|
this._events = {};
|
||||||
|
this.activeScope = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(eventName, ...args) {
|
||||||
|
const listeners = this._events[eventName];
|
||||||
|
if (listeners) {
|
||||||
|
for (const cb of listeners) cb(...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
on(eventName, callback) {
|
||||||
|
if (this._events[eventName]) {
|
||||||
|
this._events[eventName].push(callback);
|
||||||
|
} else {
|
||||||
|
this._events[eventName] = [callback];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scope(f) fires f immediately when already in scope, otherwise queues it for
|
||||||
|
// the next 'scope' event — same convenience the original TS SDK provided.
|
||||||
|
scope(f) {
|
||||||
|
if (this.activeScope) {
|
||||||
|
f();
|
||||||
|
} else {
|
||||||
|
this.on('scope', f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue