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
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
# Gitea CLI credentials — never commit
|
||||
.gitea-auth.json
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
# Go build outputs
|
||||
/mwse
|
||||
/mwse-engine
|
||||
*.out
|
||||
*.test
|
||||
go.work
|
||||
go.work.sum
|
||||
loadtest/mwse-loadtest
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.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 stays on disk (gitignored) so npm tools still work if needed,
|
||||
# but nothing in the repo should require them anymore.
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
package-lock.json
|
||||
.parcel-cache/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.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
|
||||
# Environment / secrets
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
# Editor / OS
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
# Next.js build output
|
||||
.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/*
|
||||
.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 ./...
|
||||
```
|
||||
215
README.md
215
README.md
|
|
@ -1,23 +1,214 @@
|
|||
# MWSE Nedir?
|
||||
# MWSE — Micro Web Socket Engine
|
||||
|
||||
MWSE yani Micro Web Socket Engine, kendisine bağlanan eşleri birbirleriyle ile eşleştirerek, eşler arası veri tünelleri oluşturan geniş ölçekli bir mikroservistir.
|
||||
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.
|
||||
|
||||
Servis, bağlantı sağlayan cihazların verilerini kendi aralarında senkron etmek için kullanılabilir, cihazları gruplayabilir, odalar oluşturabilir, sohbet ve görüntülü görüşme yazılımları için alt yapı olarak kullanılabilir
|
||||
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.
|
||||
|
||||
Bağlantı TCP tabanlı yüksek hızlı WebSocket protokolüne dayanır ve sunucunun cihazları sanallaştırması sayesinde diğer kişilerin IP adreslerini veya cihaz türü gibi bilgilere ihtiyaç duymadan düşük gecikmeli çift taraflı serbest iletişim kurmalarını sağlar.
|
||||
## Durum (Go engine, v1.0.0)
|
||||
|
||||
[Geliştirici Dökümanı](https://git.saqut.com/saqut/MWSE/wiki/Entegrasyon)
|
||||
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.
|
||||
|
||||
# Güvenlik !
|
||||
| Ö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 |
|
||||
|
||||
Framework, bağlı tüm cihazlar arasında mesajları doğru hedefe, verinin bozulmadığını garanti ederek iletmekden sorumludur.
|
||||
## Kurulum ve çalıştırma
|
||||
|
||||
Bunların dışında hassas verilerin soket üzerinden iletilmesi şimdilik önerilmez, clientlerin ileteceği mesajlar **SOKETE İLETİLMEDEN ÖNCE** kullanıcılar tarafından manipüle edilebilir veya taklit edilebilir ve MWSE bunun doğrulamasını **YAPMAZ**
|
||||
### Gereksinimler
|
||||
|
||||
## WebSocket topolojisi
|
||||
- Go 1.22+
|
||||
|
||||

|
||||
### Sunucuyu başlat
|
||||
|
||||
## Proje tarafından uygulanan load balance teknolojisi
|
||||
```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
|
||||
import MWSE from '/sdk/index.js';
|
||||
import { MediaSources } from '/sdk/webrtc/index.js';
|
||||
|
||||
const mwse = new MWSE();
|
||||
|
||||
mwse.me.on('accepted/pair', async peer => {
|
||||
const polite = mwse.me.socketId < peer.socketId;
|
||||
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);
|
||||
});
|
||||
```
|
||||
|
||||
### Studio UI
|
||||
|
||||
```js
|
||||
import MWSE from '/sdk/index.js';
|
||||
import Studio from '/sdk/studio/index.js';
|
||||
|
||||
const mwse = new MWSE();
|
||||
const studio = new Studio(mwse, '#app');
|
||||
mwse.on('scope', () => studio.mount());
|
||||
```
|
||||
|
||||
## Demo dosyaları
|
||||
|
||||
Sunucu çalışırken `http://localhost:7707/demos/` altında:
|
||||
|
||||
| Demo | Dosya | Açıklama |
|
||||
|---|---|---|
|
||||
| Chat | `chat.html` | ~20 satır JS ile odalı gerçek zamanlı sohbet |
|
||||
| Sesli görüşme | `audio.html` | P2P WebRTC mikrofon (çift yönlü) |
|
||||
| Video görüşme | `video.html` | P2P WebRTC kamera ızgara görünümü |
|
||||
|
||||
## 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
|
||||
|
||||
- İ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).
|
||||
|
||||
## Geliştirici dökümanı
|
||||
|
||||
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