diff --git a/README.md b/README.md index e32881f..2eb9df0 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,161 @@ -# 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, v0.1.0 paritesi ✅) -[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. -# 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) | ✅ | +| İkili çerçeveleme (binary framing) | ⏳ 2.5.0 | +| Studio (akış/WebRTC sunucusu) | ⏳ 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+ +- (Opsiyonel) TypeScript — SDK derlemek için `npm run build` -![image](https://www.hitechmv.com/wp-content/uploads/2014/05/startopology.jpg) +### Sunucuyu başlat -## Proje tarafından uygulanan load balance teknolojisi +```bash +# Bağımlılıklar +go mod tidy -![image](https://git.saqut.com/saqut/storage/raw/branch/master/Diagram1.png) \ No newline at end of file +# Çalıştır (varsayılan: 0.0.0.0:7707) +go run . + +# Yapılandırma env değişkenleriyle: +MWSE_ADDR=:8080 \ +MWSE_OUTBOUND_BUFFER=2048 \ +MWSE_MAX_MESSAGE_SIZE=33554432 \ +go run . +``` + +### Ortam değişkenleri + +| Değişken | Varsayılan | Açıklama | +|---|---|---| +| `MWSE_ADDR` | `:7707` | Dinleme adresi | +| `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ığı | +| `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, TypeScript ile yazılmış ve derlenmiş JavaScript olarak `/script` +endpoint'inden sunulur. Herhangi bir bundler gerekmez. + +```html + + + + +``` + +## Demo dosyaları + +| Demo | Yol | Açıklama | +|---|---|---| +| Chat | `/demos/chat.html` | ~20 satır JS ile odalı sohbet | +| Sesli görüşme | `/demos/audio.html` | P2P WebRTC ses (eşler arası) | +| Video görüşme | `/demos/video.html` | P2P WebRTC video (kamera ızgara görünümü) | + +## API kontrolü (/api) + +API anahtarı al, sonra kullan: + +```bash +# 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//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 + +``` +Client (WebSocket) + │ + ▼ +ws.Hub (router + registry) + ├─ services/auth.go Pairing, IP adresi, 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 senkronizasyon (CRUD broadcast) + │ Pasif senkronizasyon (hash-dedup merge pool) + ├─ services/bridge.go 3. parti sunucu inbox (bridge/send) + └─ services/ippressure.go Sanal IP basıncı + +httpserver + ├─ GET/POST /api/* Kontrol düzlemi (API anahtarı, oda/istemci yönetimi) + ├─ POST /api/bridge/inbox 3. parti sunucu inbox boşaltma + └─ /* (WebSocket değilse) SDK (script/index.js) + statik dosyalar +``` + +## Güvenlik + +- Bağlı cihazların mesajları **sokete iletilmeden önce** kullanıcılar tarafından + manipüle edilebilir; MWSE bu doğrulamayı yapmaz. Hassas veriler için + uygulama katmanında imzalama/şifreleme ekleyin. +- 3. parti köprü (`BRIDGE_APPROVE_URL`) kullanılıyorsa bağlantı onayı uygulama + sunucusuna delege edilir (fail-closed: onay gelmezse bağlantı reddedilir). + +## Geliştirici dökümanı + +Tüm wiki sayfaları: diff --git a/internal/config/config.go b/internal/config/config.go index 8d2caf8..eba4316 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,7 +15,8 @@ type Config struct { Port int // listen port, default 7707 PublicDir string // static assets directory (default "./public") - ScriptDir string // built SDK directory (default "./script") + ScriptDir string // legacy compiled SDK bundle directory (default "./script") + SDKDir string // ES-module SDK directory (default "./sdk") ReadHeaderTimeout time.Duration // HTTP read-header timeout ShutdownTimeout time.Duration // grace period for in-flight work on shutdown @@ -65,6 +66,7 @@ func Load() Config { Port: envInt("MWSE_PORT", 7707), PublicDir: env("MWSE_PUBLIC_DIR", "./public"), ScriptDir: env("MWSE_SCRIPT_DIR", "./script"), + 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), diff --git a/internal/httpserver/httpserver.go b/internal/httpserver/httpserver.go index 86b981d..c5c3a67 100644 --- a/internal/httpserver/httpserver.go +++ b/internal/httpserver/httpserver.go @@ -68,7 +68,9 @@ func New(hub *ws.Hub, cfg config.Config, srvOpts ...ServerOptions) *http.Server // registerStatic wires the asset routes: // -// - /script -> the built SDK entry (script/index.js) +// - /sdk.js -> redirect to /sdk/index.js (so import.meta.url resolves correctly) +// - /sdk/ -> ES-module SDK files (sdk/EventTarget.js, etc.) +// - /script -> legacy compiled SDK entry (script/index.js) // - /script/ -> files under the script directory // - / -> the SDK entry (so a bare visit returns the script) // - / -> a matching file under the public directory @@ -77,6 +79,13 @@ func registerStatic(mux *http.ServeMux, cfg config.Config) { scriptIndex := filepath.Join(cfg.ScriptDir, "index.js") statusDoc := filepath.Join(cfg.ScriptDir, "status.xml") + // ES-module SDK: redirect /sdk.js → /sdk/index.js so that import.meta.url + // resolves to /sdk/index.js and all relative imports go to /sdk/*. + 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)))) + mux.HandleFunc("/script", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, scriptIndex) }) diff --git a/internal/protocol/version.go b/internal/protocol/version.go new file mode 100644 index 0000000..8909606 --- /dev/null +++ b/internal/protocol/version.go @@ -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 diff --git a/internal/services/yourid.go b/internal/services/yourid.go index cc1252f..846aaa1 100644 --- a/internal/services/yourid.go +++ b/internal/services/yourid.go @@ -1,11 +1,25 @@ package services -import "git.saqut.com/saqut/mwse/internal/ws" +import ( + "git.saqut.com/saqut/mwse/internal/protocol" + "git.saqut.com/saqut/mwse/internal/ws" +) -// registerYourID tells a freshly connected client its own socket id, exactly as -// the Node YourID service did: client.send([{type:'id', value: id}, 'id']). +// 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}) }) } diff --git a/public/demos/audio.html b/public/demos/audio.html new file mode 100644 index 0000000..ea3a4fb --- /dev/null +++ b/public/demos/audio.html @@ -0,0 +1,127 @@ + + + + + MWSE — Sesli Görüşme Demo + + + + +

MWSE Sesli Görüşme Demo

+

+ 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, max ~10–15 kişi için + mesh topolojisi; daha fazlası için SRS entegrasyonu gerekir). +

+
Bağlanıyor…
+
+ + + + diff --git a/public/demos/chat.html b/public/demos/chat.html new file mode 100644 index 0000000..6479bab --- /dev/null +++ b/public/demos/chat.html @@ -0,0 +1,67 @@ + + + + + MWSE — Chat Demo + + + + +

MWSE Chat Demo

+

Bağlanıyor…

+
+
+ + +
+ + + + diff --git a/public/demos/video.html b/public/demos/video.html new file mode 100644 index 0000000..c44c3d6 --- /dev/null +++ b/public/demos/video.html @@ -0,0 +1,148 @@ + + + + + MWSE — Video Demo + + + + + +
+ + + + diff --git a/sdk/Connection.js b/sdk/Connection.js new file mode 100644 index 0000000..1557580 --- /dev/null +++ b/sdk/Connection.js @@ -0,0 +1,107 @@ +// 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) { + if (typeof data === 'string') { + const parsed = JSON.parse(data); + for (const cb of this._packCallbacks) cb(parsed); + } else if (data instanceof ArrayBuffer) { + // Binary frame — passed raw to WSTSProtocol for codec decoding. + 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); + } +} diff --git a/sdk/EventPool.js b/sdk/EventPool.js new file mode 100644 index 0000000..6a30c78 --- /dev/null +++ b/sdk/EventPool.js @@ -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]); + } + } +} diff --git a/sdk/EventTarget.js b/sdk/EventTarget.js new file mode 100644 index 0000000..d40657a --- /dev/null +++ b/sdk/EventTarget.js @@ -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); + } + } +} diff --git a/sdk/IPPressure.js b/sdk/IPPressure.js new file mode 100644 index 0000000..63461c8 --- /dev/null +++ b/sdk/IPPressure.js @@ -0,0 +1,86 @@ +// Virtual address / IP pressure system. +// Lets a connection claim a human-friendly alias (short code, number, IP-style address). + +export class IPPressure { + constructor(mwse) { + this.mwse = mwse; + this.APNumber = undefined; + this.APShortCode = undefined; + this.APIPAddress = undefined; + } + + async allocAPIPAddress() { + const { status, ip } = await this.mwse.EventPooling.request({ type: 'alloc/APIPAddress' }); + if (status === 'success') { this.APIPAddress = ip; return ip; } + throw new Error('Error allocating virtual IP address'); + } + + async allocAPNumber() { + const { status, number } = await this.mwse.EventPooling.request({ type: 'alloc/APNumber' }); + if (status === 'success') { this.APNumber = number; return number; } + throw new Error('Error allocating virtual number'); + } + + async allocAPShortCode() { + const { status, code } = await this.mwse.EventPooling.request({ type: 'alloc/APShortCode' }); + if (status === 'success') { this.APShortCode = code; return code; } + throw new Error('Error allocating virtual short code'); + } + + async reallocAPIPAddress() { + const { status, ip } = await this.mwse.EventPooling.request({ type: 'realloc/APIPAddress' }); + if (status === 'success') { this.APIPAddress = ip; return ip; } + throw new Error('Error reallocating virtual IP address'); + } + + async reallocAPNumber() { + const { status, number } = await this.mwse.EventPooling.request({ type: 'realloc/APNumber' }); + if (status === 'success') { this.APNumber = number; return number; } + throw new Error('Error reallocating virtual number'); + } + + async reallocAPShortCode() { + const { status, code } = await this.mwse.EventPooling.request({ type: 'realloc/APShortCode' }); + if (status === 'success') { this.APShortCode = code; return code; } + throw new Error('Error reallocating virtual short code'); + } + + async releaseAPIPAddress() { + const { status } = await this.mwse.EventPooling.request({ type: 'release/APIPAddress' }); + if (status === 'success') { this.APIPAddress = undefined; return; } + throw new Error('Error releasing virtual IP address'); + } + + async releaseAPNumber() { + const { status } = await this.mwse.EventPooling.request({ type: 'release/APNumber' }); + if (status === 'success') { this.APNumber = undefined; return; } + throw new Error('Error releasing virtual number'); + } + + async releaseAPShortCode() { + const { status } = await this.mwse.EventPooling.request({ type: 'release/APShortCode' }); + if (status === 'success') { this.APShortCode = undefined; return; } + throw new Error('Error releasing virtual short code'); + } + + async queryAPIPAddress(ip) { + const { status, socket } = await this.mwse.EventPooling.request({ + type: 'whois/APIPAddress', whois: ip + }); + return status === 'success' ? socket : null; + } + + async queryAPNumber(number) { + const { status, socket } = await this.mwse.EventPooling.request({ + type: 'whois/APNumber', whois: number + }); + return status === 'success' ? socket : null; + } + + async queryAPShortCode(code) { + const { status, socket } = await this.mwse.EventPooling.request({ + type: 'whois/APShortCode', whois: code + }); + return status === 'success' ? socket : null; + } +} diff --git a/sdk/P2PFileSender.js b/sdk/P2PFileSender.js new file mode 100644 index 0000000..6caa800 --- /dev/null +++ b/sdk/P2PFileSender.js @@ -0,0 +1,154 @@ +// P2P file transfer over WebRTC data channels. Experimental — do not use in +// production without testing. Requires a live WebRTC peer connection. + +export default class P2PFileSender { + constructor(webrtc, peer) { + this.webrtc = webrtc; + this.rtc = webrtc.rtc; + this.peer = peer; + + this.totalSize = 0; + this.isReady = false; + this.isStarted = false; + this.isSending = false; + this.isRecaiving = false; + this.processedSize = 0; + + this.bufferSizePerChannel = 10e6; + this.bufferSizePerPack = 10e3; + this.safeBufferSizePerPack = 10e3 - 1; + } + + async RecaiveFile(fileMetadata, channelCount, _totalSize, onEnded) { + let parts = []; + this.webrtc.on('datachannel', datachannel => { + let current = 0; + let totalSize = 0; + let currentPart = 0; + let bufferAmount = []; + + datachannel.onmessage = ({ data }) => { + if (totalSize === 0) { + const { size, part } = JSON.parse(data); + totalSize = size; + currentPart = part; + datachannel.send('READY'); + } else { + current += data.byteLength; + bufferAmount.push(data); + if (current === totalSize) { + parts[currentPart] = new Blob(bufferAmount); + bufferAmount = []; + totalSize = 0; + currentPart = 0; + current = 0; + datachannel.send('TOTAL_RECAIVED'); + } + } + }; + + datachannel.onclose = () => { + channelCount--; + if (channelCount === 0) { + const file = new File(parts, fileMetadata.name, { + type: fileMetadata.type, + lastModified: Date.now() + }); + onEnded(file); + } + }; + }); + } + + async SendFile(file, metadata) { + this.isSending = true; + this.isStarted = true; + + const buffer = await file.arrayBuffer(); + const partCount = Math.ceil(buffer.byteLength / 10e6); + const channelCount = Math.min(5, partCount); + + if (this.webrtc.iceStatus !== 'connected') { + throw new Error('WebRTC is not ready'); + } + + this.peer.send({ + type: 'file', + name: file.name, + size: file.size, + mimetype: file.type, + partCount, + channelCount, + metadata + }); + + const channels = []; + for (let i = 0; i < channelCount; i++) { + const channel = this.rtc.createDataChannel('\\?\\file_' + i); + channel.binaryType = 'arraybuffer'; + await new Promise(ok => { channel.onopen = () => ok(); }); + channels.push(channel); + } + + let currentPart = 0; + const next = () => { + if (currentPart < partCount) { + const part = buffer.slice(currentPart * 10e6, (currentPart + 1) * 10e6); + return [part, currentPart++]; + } + return [false, 0]; + }; + + let pending = channels.length; + await new Promise(ok => { + for (let i = 0; i < channels.length; i++) { + this._sendPartition(channels[i], next, () => { + if (--pending === 0) { + this.isSending = false; + this.isStarted = false; + ok(); + } + }); + } + }); + } + + _sendPartition(channel, nextblob, onEnded) { + let [currentBuffer, currentPartition] = nextblob(); + let currentPart = 0; + + const nextChunk = () => { + if (!(currentBuffer instanceof ArrayBuffer)) return; + const chunk = currentBuffer.slice(currentPart * 16e3, (currentPart + 1) * 16e3); + currentPart++; + return chunk.byteLength ? chunk : undefined; + }; + + channel.addEventListener('message', ({ data }) => { + if (data === 'READY') { + this._sendChannel(channel, nextChunk); + } else if (data === 'TOTAL_RECAIVED') { + [currentBuffer, currentPartition] = nextblob(); + currentPart = 0; + if (currentBuffer !== false) { + channel.send(JSON.stringify({ size: currentBuffer.byteLength, part: currentPartition })); + } else { + channel.close(); + onEnded(); + } + } + }); + + channel.send(JSON.stringify({ size: currentBuffer.byteLength, part: currentPartition })); + } + + _sendChannel(channel, getNextChunk) { + channel.addEventListener('bufferedamountlow', () => { + const chunk = getNextChunk(); + if (chunk) channel.send(chunk); + }); + channel.bufferedAmountLowThreshold = 16e3 - 1; + const first = getNextChunk(); + if (first) channel.send(first); + } +} diff --git a/sdk/Peer.js b/sdk/Peer.js new file mode 100644 index 0000000..662f0f0 --- /dev/null +++ b/sdk/Peer.js @@ -0,0 +1,195 @@ +import MWSEEventTarget from './EventTarget.js'; +import { PeerInfo } from './PeerInfo.js'; +import WebRTC from './WebRTC.js'; + +export default class Peer extends MWSEEventTarget { + constructor(mwse) { + super(); + this.mwse = mwse; + this.options = {}; + this.socketId = undefined; + this.selfSocket = false; + this.active = false; + this.peerConnection = false; + this.primaryChannel = 'datachannel'; + + this.info = new PeerInfo(this); + this.rtc = this._createRTC(); + + // Route incoming packs: RTC signaling goes to the RTC engine, + // everything else surfaces as a 'message' event. + this.on('pack', data => { + if (data.type === ':rtcpack:') { + this.rtc.emit('input', data.payload); + } else { + this.emit('message', data); + } + }); + } + + _createRTC(rtcConfig, rtcServers) { + const rtc = new WebRTC(rtcConfig, rtcServers); + rtc.peer = this; + + rtc.on('connected', () => { this.peerConnection = true; }); + rtc.on('disconnected', () => { this.peerConnection = false; }); + + // RTC output → relay opaque `:rtcpack:` to the paired peer via server. + rtc.on('output', payload => { + this.send({ type: ':rtcpack:', payload }); + }); + + // RTC data-channel message → surface as peer 'pack' event. + rtc.on('message', payload => { + this.emit('pack', payload); + }); + + this.rtc = rtc; + return rtc; + } + + // createRTC allows callers to (re)create the RTC object with custom config. + createRTC(rtcConfig, rtcServers) { + return this._createRTC(rtcConfig, rtcServers); + } + + setPeerOptions(options) { + if (typeof options === 'string') { + this.setSocketId(options); + } else { + this.options = options; + } + } + + setSocketId(uuid) { + this.socketId = uuid; + } + + async metadata() { + if (this.socketId === 'me') { + const result = await this.mwse.EventPooling.request({ type: 'my/socketid' }); + this.selfSocket = true; + this.active = this.active || true; + this.socketId = result; + this.emit('scope'); + this.activeScope = true; + return result; + } + } + + async request(pack) { + if (this.active) { + return this.mwse.request(this.socketId, pack); + } + } + + equalTo(peer) { + return this.socketId === peer.socketId; + } + + async isReachable() { + return 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() { + const { message, status } = await this.mwse.EventPooling.request({ + type: 'request/pair', + to: this.socketId + }); + if (message === 'ALREADY-PAIRED' || message === 'ALREADY-REQUESTED') { + console.warn('MWSE: already paired or pair requested'); + } + if (status === 'fail') { + console.error('MWSE: requestPair failed', status, message); + return false; + } + return true; + } + + async endPair() { + await this.mwse.EventPooling.request({ type: 'end/pair', to: this.socketId }); + this.forget(); + } + + async acceptPair() { + const { message, status } = await this.mwse.EventPooling.request({ + type: 'accept/pair', + to: this.socketId + }); + if (status === 'fail') { + console.error('MWSE: acceptPair failed', status, message); + return false; + } + return true; + } + + async rejectPair() { + const { message, status } = await this.mwse.EventPooling.request({ + type: 'reject/pair', + to: this.socketId + }); + if (status === 'fail') { + console.error('MWSE: rejectPair failed', status, message); + return false; + } + return true; + } + + async getPairedList() { + const { value } = await this.mwse.EventPooling.request({ + type: 'pair/list', + to: this.socketId + }); + return value; + } + + async send(pack) { + const p2pOpen = this.peerConnection && this.rtc?.active; + const serverOpen = this.mwse.server.connected; + + let channel; + if (p2pOpen && serverOpen) { + channel = this.primaryChannel === 'websocket' ? 'websocket' : 'datachannel'; + } else if (serverOpen) { + channel = 'websocket'; + } else { + channel = 'datachannel'; + } + + if (channel === 'websocket') { + if (!this.mwse.writable) { + console.warn('MWSE: socket is not writable'); + return; + } + // WOM — no waiter registered; the engine returns nil for pack/to (#33). + this.mwse.EventPooling.only({ type: 'pack/to', pack, to: this.socketId }); + } else { + if (pack.type !== ':rtcpack:') { + this.rtc?.sendMessage(pack); + } else { + console.warn('MWSE: cannot send :rtcpack: over data channel'); + } + } + } + + forget() { + this.mwse.peers.delete(this.socketId); + this.mwse.pairs.delete(this.socketId); + } +} diff --git a/sdk/PeerInfo.js b/sdk/PeerInfo.js new file mode 100644 index 0000000..52f777e --- /dev/null +++ b/sdk/PeerInfo.js @@ -0,0 +1,29 @@ +export class PeerInfo { + constructor(peer) { + this.peer = peer; + this.info = {}; + } + + async fetch(name) { + const req = name + ? { type: 'peer/info', peer: this.peer.socketId, name } + : { type: 'peer/info', peer: this.peer.socketId }; + + const rinfo = await this.peer.mwse.EventPooling.request(req); + if (rinfo.status === 'success') { + this.info = rinfo.info; + } else { + console.warn(rinfo.message); + } + return this.info; + } + + set(name, value) { + this.info[name] = value; + this.peer.mwse.WSTSProtocol.SendOnly({ type: 'auth/info', name, value }); + } + + get(name) { + return name ? this.info[name] : this.info; + } +} diff --git a/sdk/Room.js b/sdk/Room.js new file mode 100644 index 0000000..5d1816d --- /dev/null +++ b/sdk/Room.js @@ -0,0 +1,124 @@ +import MWSEEventTarget from './EventTarget.js'; +import { RoomInfo } from './RoomInfo.js'; + +export default class Room extends MWSEEventTarget { + constructor(mwse) { + super(); + this.mwse = mwse; + this.peers = new Map(); + this.info = new RoomInfo(this); + + this.options = undefined; + this.config = undefined; + this.roomId = undefined; + this.accessType = undefined; + this.joinType = undefined; + this.name = undefined; + this.owner = undefined; + } + + setRoomOptions(options) { + if (typeof options === 'string') { + this.roomId = options; + } else { + const defaults = { + joinType: 'free', + ifexistsJoin: true, + accessType: 'private', + notifyActionInvite: true, + notifyActionJoined: true, + notifyActionEjected: true, + autoFetchInfo: true + }; + this.config = Object.assign(defaults, options); + } + } + + setRoomId(uuid) { + this.roomId = uuid; + } + + async createRoom(roomOptions) { + const config = this.config || roomOptions; + const 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); + } + + this.options = { ...this.config, ...result.room }; + this.roomId = result.room.id; + this.mwse.rooms.set(this.roomId, this); + } + + async join() { + const 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); + + 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, this); + } + + async eject() { + const { type } = await this.mwse.EventPooling.request({ + type: 'ejectroom', + roomId: this.roomId + }); + this.peers.clear(); + if (type === 'success') this.mwse.rooms.delete(this.roomId); + } + + async send(pack, wom = false, handshake = false) { + if (!this.mwse.writable) { + console.warn('MWSE: socket is not writable'); + return; + } + if (handshake) { + const { type } = await this.mwse.EventPooling.request({ + type: 'pack/room', pack, to: this.roomId, wom, handshake + }); + if (type === 'fail') throw new Error('Cannot send message to room'); + } else { + // WOM broadcast — fire-and-forget, no waiter (#33). + this.mwse.EventPooling.only({ type: 'pack/room', pack, to: this.roomId, wom, handshake }); + } + } + + async fetchPeers(filter, onlyNumber = false) { + if (onlyNumber) { + const { count } = await this.mwse.EventPooling.request({ + type: 'room/peer-count', + roomId: this.roomId, + filter: filter || {} + }); + return count; + } + + const { status, peers } = await this.mwse.EventPooling.request({ + type: 'room-peers', + roomId: this.roomId, + filter: filter || {} + }); + + if (status === 'fail') throw new Error('Cannot fetch peers from room'); + + const result = []; + for (const peerId of peers) { + const peer = this.mwse.peer(peerId, true); + result.push(peer); + this.peers.set(peerId, peer); + } + return result; + } +} diff --git a/sdk/RoomInfo.js b/sdk/RoomInfo.js new file mode 100644 index 0000000..e9a710c --- /dev/null +++ b/sdk/RoomInfo.js @@ -0,0 +1,36 @@ +export class RoomInfo { + constructor(room) { + this.room = room; + this.info = {}; + // Keep local cache in sync with server-pushed updates. + this.room.on('updateinfo', (name, value) => { this.info[name] = value; }); + } + + async fetch(name) { + const req = name + ? { type: 'room/getinfo', roomId: this.room.roomId, name } + : { type: 'room/info', roomId: this.room.roomId }; + + const rinfo = await this.room.mwse.EventPooling.request(req); + if (rinfo.status === 'success') { + this.info = rinfo.value; + } else { + console.warn(rinfo.message); + } + return this.info; + } + + set(name, value) { + this.info[name] = value; + this.room.mwse.WSTSProtocol.SendOnly({ + type: 'room/setinfo', + roomId: this.room.roomId, + name, + value + }); + } + + get(name) { + return name ? this.info[name] : this.info; + } +} diff --git a/sdk/WSTSProtocol.js b/sdk/WSTSProtocol.js new file mode 100644 index 0000000..3b98c20 --- /dev/null +++ b/sdk/WSTSProtocol.js @@ -0,0 +1,71 @@ +// WSTS protocol layer — sits between the raw WebSocket and the event pool. +// Routes incoming frames to either a correlated reply handler (numeric id) or +// a named signal handler (string id). Outbound frames are encoded by the codec. + +import { WSTSCodec } from './codec.js'; + +export default class WSTSProtocol { + constructor(mwse) { + this.mwse = mwse; + this.codec = new WSTSCodec(); + this._listen(); + } + + _listen() { + this.mwse.server.onRecaivePack(raw => this.PackAnalyze(raw)); + } + + // ---- Outbound ------------------------------------------------------- + + // SendOnly: fire-and-forget WOM frame — no id, no pending waiter. + SendOnly(pack) { + this.mwse.server.tranferToServer(this.codec.encode([pack, 'R'])); + } + + // SendRequest: request expecting exactly one reply (action 'R' → server replies 'E'). + SendRequest(pack, id) { + this.mwse.server.tranferToServer(this.codec.encode([pack, id, 'R'])); + } + + // StartStream: request expecting multiple replies (server replies 'C' until done). + StartStream(pack, id) { + this.mwse.server.tranferToServer(this.codec.encode([pack, id, 'S'])); + } + + // SendRaw: bypass codec (used by internal tooling only). + SendRaw(pack) { + this.mwse.server.tranferToServer(JSON.stringify(pack)); + } + + // ---- Inbound -------------------------------------------------------- + + // PackAnalyze routes one decoded frame to the right listener. + // raw may be a string (JSON) or an ArrayBuffer (binary, when #42 lands). + PackAnalyze(raw) { + const data = this.codec.decode(raw); + const [payload, id, action] = data; + + if (typeof id === 'number') { + // Correlated reply: numeric id maps to a pending request. + const entry = this.mwse.EventPooling.events.get(id); + if (entry) { + entry[0](payload, action); + if (action === 'E') { + // 'E'nd — one-shot request fulfilled; remove the waiter. + this.mwse.EventPooling.events.delete(id); + } + // 'C'ontinue — stream replies stay in the map until the server closes. + } else { + console.warn('MWSE: reply for unknown request id', id); + } + } else { + // Signal: string id (signal name) fans out to all registered listeners. + const listeners = this.mwse.EventPooling.signals.get(id); + if (listeners) { + for (const cb of listeners) cb(payload); + } else { + console.warn('MWSE: unhandled signal', id); + } + } + } +} diff --git a/sdk/WebRTC.js b/sdk/WebRTC.js new file mode 100644 index 0000000..f9fabed --- /dev/null +++ b/sdk/WebRTC.js @@ -0,0 +1,36 @@ +// WebRTC.js — placeholder. Full WebRTC implementation will be rewritten separately. +// Provides the interface Peer.js depends on so the rest of the SDK loads cleanly. +import MWSEEventTarget from './EventTarget.js'; + +export default class WebRTC extends MWSEEventTarget { + constructor(_rtcConfig, _rtcServers) { + super(); + this.active = false; + this.peer = null; + this.connectionStatus = 'new'; + this.iceStatus = 'new'; + this.gatheringStatus = 'new'; + this.signalingStatus = ''; + this.channel = undefined; + } + + connect() { + console.warn('WebRTC: not yet implemented — replacement coming separately'); + } + + sendStream(_stream, _name, _info) { + console.warn('WebRTC.sendStream: not yet implemented'); + } + + sendMessage(_data) { + console.warn('WebRTC.sendMessage: not yet implemented'); + } + + destroy() { + this.active = false; + this.emit('disconnected'); + } + + // send is called internally by the signaling output path in the full implementation. + send(_data) {} +} diff --git a/sdk/codec.js b/sdk/codec.js new file mode 100644 index 0000000..ae86ba8 --- /dev/null +++ b/sdk/codec.js @@ -0,0 +1,75 @@ +// WSTS codec — encoder/decoder for the WebSocket frames MWSE exchanges. +// +// Infrastructure for issue #42 (binary framing). The codec is pluggable: today +// it speaks JSON text frames (mode=CODEC_JSON); the binary path (mode=CODEC_BINARY) +// is stubbed and will be wired once the server side lands. +// +// Frame shapes (codec-agnostic, defined by WSTS protocol): +// Inbound from server: [payload, signalName] — signal +// [payload, id, 'E'|'C'] — reply +// Outbound to server: [msg, 'R'] — fire-and-forget (WOM) +// [msg, id, 'R'] — request (reply once) +// [msg, id, 'S'] — stream (reply multiple) +import { CODEC_JSON, CODEC_BINARY } from './version.js'; + +export class WSTSCodec { + constructor() { + this.mode = CODEC_JSON; + } + + // encode turns a JS frame array into a wire value (string or ArrayBuffer). + encode(frame) { + if (this.mode === CODEC_JSON) { + return JSON.stringify(frame); + } + if (this.mode === CODEC_BINARY) { + return this._encodeBinary(frame); + } + throw new Error(`WSTSCodec: unknown mode ${this.mode}`); + } + + // decode turns raw wire data into a JS frame array. + decode(raw) { + if (typeof raw === 'string') { + return JSON.parse(raw); + } + if (raw instanceof ArrayBuffer) { + if (this.mode === CODEC_BINARY) { + return this._decodeBinary(raw); + } + throw new Error('WSTSCodec: received binary frame but codec is in JSON mode'); + } + throw new Error(`WSTSCodec: unexpected frame type ${typeof raw}`); + } + + // negotiate picks the best codec from the server's offered list and activates it. + // Returns true when a common codec was found. + negotiate(serverCodecs) { + // Prefer binary when both sides support it (future upgrade path). + if (serverCodecs.includes(CODEC_BINARY)) { + this.mode = CODEC_BINARY; + return true; + } + if (serverCodecs.includes(CODEC_JSON)) { + this.mode = CODEC_JSON; + return true; + } + return false; + } + + // ---- Binary codec (issue #42) ---------------------------------------- + // Wire layout: 1-byte opcode | 4-byte uint32 big-endian payload length | payload bytes + // Opcodes: 0x01 = signal, 0x02 = reply-end ('E'), 0x03 = reply-continue ('C'), + // 0x04 = request ('R'), 0x05 = stream ('S'), 0x06 = wom ('R' no id) + // Payload: MessagePack (or CBOR) encoded frame contents. + // This is intentionally NOT implemented yet — throws so callers know when they + // accidentally reach binary paths before #42 is merged. + + _encodeBinary(_frame) { + throw new Error('WSTSCodec binary encoding not yet implemented (#42)'); + } + + _decodeBinary(_buf) { + throw new Error('WSTSCodec binary decoding not yet implemented (#42)'); + } +} diff --git a/sdk/index.js b/sdk/index.js new file mode 100644 index 0000000..3e69d15 --- /dev/null +++ b/sdk/index.js @@ -0,0 +1,262 @@ +// MWSE SDK — ES module entry point. +// +// Load via: +// +// or through the /sdk.js redirect: +// +// +// Because this is an ES module, all imports below resolve relative to +// import.meta.url (= the URL of this file on the MWSE server). Every other SDK +// file therefore loads from the same origin automatically — no bundler needed. +// +// Version handshake: +// On connect the server sends a wsts/hello signal carrying its version string. +// If it does not match SDK_VERSION the SDK fires an 'error' event, closes the +// connection, and never fires 'scope'. This prevents accidental use of an SDK +// against an incompatible engine. + +import { SDK_VERSION } from './version.js'; +import { Connection } from './Connection.js'; +import WSTSProtocol from './WSTSProtocol.js'; +import EventPool from './EventPool.js'; +import { IPPressure } from './IPPressure.js'; +import Peer from './Peer.js'; +import Room from './Room.js'; + +export default class MWSE { + constructor(options) { + this.rooms = new Map(); + this.pairs = new Map(); + this.peers = new Map(); + + this.writable = 1; + this.readable = 1; + + this._events = {}; + this.activeScope = false; + + this.server = new Connection(this, options); + this.WSTSProtocol = new WSTSProtocol(this); + this.EventPooling = new EventPool(this); + this.virtualPressure = new IPPressure(this); + + this.me = new Peer(this); + this.me.scope(() => { + this.peers.set('me', this.me); + this.peers.set(this.me.socketId, this.me); + }); + + this._wireSignals(); + + this.server.connect(); + + // Version handshake happens before scope. onActive waits for wsts/hello; + // only on success does it fire the user's scope callbacks. + this.server.onActive(async () => { + try { + await this._awaitHello(); + } catch (err) { + this.emit('error', err); + return; + } + this.me.setSocketId('me'); + await this.me.metadata(); + this.emit('scope'); + this.activeScope = true; + }); + + this.server.onPassive(() => { + this.emit('close'); + }); + } + + // ---- Version handshake ---------------------------------------------- + + // _awaitHello waits for the server's wsts/hello signal and validates the + // version. Resolves on success; rejects (and closes the connection) on + // mismatch or timeout. + _awaitHello() { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('MWSE: wsts/hello timeout — server did not send a version handshake')); + }, 5000); + + this.EventPooling.signal('wsts/hello', ({ v, codecs }) => { + clearTimeout(timer); + + if (v !== SDK_VERSION) { + this.server.disconnect(); + reject(new Error( + `MWSE version mismatch — server: ${v}, SDK: ${SDK_VERSION}. ` + + 'Update both to the same version.' + )); + return; + } + + // Negotiate the best codec both sides support. + this.WSTSProtocol.codec.negotiate(codecs || [0]); + resolve(); + }); + }); + } + + // ---- Event emitter (base, inlined) ---------------------------------- + + on(eventName, callback) { + (this._events[eventName] ??= []).push(callback); + } + + emit(eventName, ...args) { + for (const cb of (this._events[eventName] || [])) cb(...args); + } + + // scope(f) fires f now if already in scope, otherwise on the next 'scope' event. + scope(f) { + if (this.activeScope) f(); + else this.on('scope', f); + } + + // ---- Peer helpers --------------------------------------------------- + + room(options) { + if (typeof options === 'string') { + if (this.rooms.has(options)) return this.rooms.get(options); + } + const r = new Room(this); + r.setRoomOptions(options); + this.emit('room'); + return r; + } + + peer(options, isActive = false) { + if (typeof options === 'string') { + if (this.peers.has(options)) return this.peers.get(options); + if (this.pairs.has(options)) return this.pairs.get(options); + } + const p = new Peer(this); + p.setPeerOptions(options); + p.active = isActive; + this.peers.set(p.socketId, p); + this.emit('peer', p); + return p; + } + + async request(peerId, pack) { + const { pack: answer } = await this.EventPooling.request({ + type: 'request/to', + to: peerId, + pack + }); + return answer; + } + + async response(peerId, requestId, pack) { + this.WSTSProtocol.SendOnly({ type: 'response/to', to: peerId, pack, id: requestId }); + } + + // ---- Session flags -------------------------------------------------- + + enableRecaiveData() { this.WSTSProtocol.SendOnly({ type: 'connection/packrecaive', value: 1 }); this.readable = 1; } + disableRecaiveData() { this.WSTSProtocol.SendOnly({ type: 'connection/packrecaive', value: 0 }); this.readable = 0; } + enableSendData() { this.WSTSProtocol.SendOnly({ type: 'connection/packsending', value: 1 }); this.writable = 1; } + disableSendData() { this.WSTSProtocol.SendOnly({ type: 'connection/packsending', value: 0 }); this.writable = 0; } + enableNotifyRoomInfo() { this.WSTSProtocol.SendOnly({ type: 'connection/roominfo', value: 1 }); } + disableNotifyRoomInfo() { this.WSTSProtocol.SendOnly({ type: 'connection/roominfo', value: 0 }); } + + destroy() { this.server.disconnect(); } + + // ---- Signal wiring -------------------------------------------------- + + _wireSignals() { + const ep = this.EventPooling; + + ep.signal('pack', ({ from, pack }) => { + if (this.readable) { + this.peer(from, true).emit('pack', pack); + } + }); + + ep.signal('request', ({ from, pack, id }) => { + const scope = { + body: pack, + response: (replyPack) => this.response(from, id, replyPack), + peer: this.peer(from, true) + }; + this.peer(from, true).emit('request', scope); + this.peer('me').emit('request', scope); + }); + + ep.signal('pack/room', ({ from, pack, sender }) => { + if (this.readable) { + this.room(from).emit('message', pack, this.peer(sender)); + } + }); + + ep.signal('room/joined', ({ id, roomid }) => { + const room = this.room(roomid); + const peer = this.peer(id, true); + room.peers.set(peer.socketId, peer); + room.emit('join', peer); + }); + + ep.signal('room/info', ({ roomId, name, value }) => { + this.room(roomId).emit('updateinfo', name, value); + }); + + ep.signal('room/ejected', ({ id, roomid }) => { + const room = this.room(roomid); + const peer = this.peer(id, true); + room.peers.delete(peer.socketId); + room.emit('eject', peer); + }); + + ep.signal('room/closed', ({ roomid }) => { + const room = this.room(roomid); + room.peers.clear(); + room.emit('close'); + this.rooms.delete(roomid); + }); + + ep.signal('pair/info', ({ from, name, value }) => { + this.peer(from, true).info.info[name] = value; + this.peer(from, true).emit('info', name, value); + }); + + ep.signal('request/pair', ({ from, info }) => { + const peer = this.peer(from, true); + peer.info.info = info; + peer.emit('request/pair', peer); + this.peer('me').emit('request/pair', peer); + }); + + ep.signal('peer/disconnect', ({ id }) => { + this.peer(id, true).emit('disconnect'); + }); + + ep.signal('accepted/pair', ({ from, info }) => { + const peer = this.peer(from, true); + peer.info.info = info; + peer.emit('accepted/pair', peer); + this.peer('me').emit('accepted/pair', peer); + }); + + ep.signal('end/pair', ({ from, info }) => { + const peer = this.peer(from, true); + peer.emit('end/pair', info); + this.peer('me').emit('end/pair', from, info); + }); + + // server/pack — message pushed by the application server via /api/client/:id/send + ep.signal('server/pack', ({ from, fromServer, pack }) => { + if (this.readable) { + this.emit('server/pack', { from, fromServer, pack }); + } + }); + } +} + +// Expose on window for non-module usage patterns (e.g. inline