diff --git a/public/studio/Studio.js b/public/studio/Studio.js index 58548c3..c6f1b4e 100644 --- a/public/studio/Studio.js +++ b/public/studio/Studio.js @@ -1,205 +1,371 @@ -// MWSE Studio — desktop-first Miller-column UI. -// -// Hierarchy: Network → Groups → Peers → Devices → Streams → Quality -// -// Usage: -// import Studio from '/studio/Studio.js'; -// const studio = new Studio(mwse, '#app'); -// studio.mount(); -// -// The Studio is purely additive: it renders into the given container and -// never modifies the MWSE SDK state except through the public SDK API. -import ColumnView from '/studio/ColumnView.js'; +// MWSE Studio — dahili yönetim arayüzü. +// Miller kolonlar: Giriş → Eşler / Odalar / Cihazlar → Eylemler → Akışlar → Kalite +import ColumnView from '/studio/ColumnView.js'; import { MediaSources } from '/sdk/webrtc/index.js'; export default class Studio { constructor(mwse, container) { - this.mwse = mwse; - - this._container = typeof container === 'string' - ? document.querySelector(container) - : container; - - this._view = new ColumnView(this._container); - this._styleInjected = false; + this.mwse = mwse; + this._el = typeof container === 'string' + ? document.querySelector(container) : container; + this._view = new ColumnView(this._el); + this._devices = { cameras: [], microphones: [] }; + this._statusEl = null; + this._styleOK = false; + this._remoteMedia = []; } - // Mount the Studio UI inside the container element. - mount() { + async mount() { + this._el.classList.add('mwse-studio'); this._injectStyle(); - this._container.classList.add('mwse-studio'); - - // Toolbar - const toolbar = document.createElement('div'); - toolbar.className = 'mwse-studio__toolbar'; - - const title = document.createElement('span'); - title.className = 'mwse-studio__title'; - title.textContent = 'MWSE Studio'; - toolbar.appendChild(title); - - this._statusEl = document.createElement('span'); - this._statusEl.className = 'mwse-studio__status'; - toolbar.appendChild(this._statusEl); - - this._container.appendChild(toolbar); - - this.mwse.on('scope', () => this._setStatus('online', 'Connected')); - this.mwse.on('close', () => this._setStatus('', 'Disconnected')); - this.mwse.on('error', e => this._setStatus('error', e.message)); - + this._buildToolbar(); this._view.mount(); - this._pushNetworkColumn(); + + // Cihazları yükle + await this._loadDevices(); + + // Kök kolon + this._pushRootColumn(); + + // Gelen eşleme isteği bildirimi + this.mwse.me.on('request/pair', peer => { + this._setStatus('', `${peer.socketId.slice(-8)}… bağlanmak istiyor`); + }); + + // Yeni eşleşme → gelen akışları dinle + kolon yenile + this.mwse.me.on('accepted/pair', peer => { + this._watchIncoming(peer); + this._view.refresh(); + }); + + this.mwse.me.on('end/pair', () => this._view.refresh()); + this.mwse.on('room', () => this._view.refresh()); return this; } - // ---- Column builders ------------------------------------------------ + // ── Araç çubuğu ────────────────────────────────────────────────────────── - _pushNetworkColumn() { - const items = [{ - icon: '◉', - label: 'Network', - meta: () => `${this.mwse.peers.size} peers online`, - onSelect: () => { - this._view.popTo(1); - this._pushGroupsColumn(); + _buildToolbar() { + const bar = document.createElement('div'); + bar.className = 'mwse-studio__toolbar'; + + const logo = document.createElement('span'); + logo.className = 'mwse-studio__title'; + logo.innerHTML = 'MWSE Studio'; + bar.appendChild(logo); + + const idEl = document.createElement('span'); + idEl.className = 'mwse-studio__status'; + idEl.style.fontFamily = 'monospace'; + idEl.textContent = '…'; + this.mwse.me.on('scope', () => { idEl.textContent = this.mwse.me.socketId; }); + bar.appendChild(idEl); + + this._statusEl = document.createElement('span'); + this._statusEl.className = 'mwse-studio__status mwse-studio__status--online'; + this._statusEl.textContent = 'Bağlı'; + bar.appendChild(this._statusEl); + + // Kök'e dönme butonu + const homeBtn = document.createElement('button'); + homeBtn.className = 'mwse-btn'; + homeBtn.style.cssText = 'margin-left:auto;padding:3px 10px;font-size:11px'; + homeBtn.textContent = '⌂ Ana'; + homeBtn.addEventListener('click', () => { + this._view.popTo(0); + this._pushRootColumn(); + }); + bar.appendChild(homeBtn); + + this._el.insertBefore(bar, this._el.firstChild); + } + + // ── Kök kolon ───────────────────────────────────────────────────────────── + + _pushRootColumn() { + const items = [ + { + icon: '●', + label: 'Eşler', + meta: () => { + const n = this.mwse.pairs.size; + return n ? `${n} aktif eşleşme` : 'Henüz eş yok'; + }, + onSelect: () => { this._view.popTo(1); this._pushPeersColumn(); } + }, + { + icon: '#', + label: 'Odalar', + meta: () => { + const n = this.mwse.rooms.size; + return n ? `${n} oda` : 'Oda yok'; + }, + onSelect: () => { this._view.popTo(1); this._pushRoomsColumn(); } + }, + { + icon: '◎', + label: 'Kameralar', + meta: () => `${this._devices.cameras.length} kamera`, + onSelect: () => { this._view.popTo(1); this._pushDevicesColumn(null, 'video'); } + }, + { + icon: '♪', + label: 'Mikrofonlar', + meta: () => `${this._devices.microphones.length} mikrofon`, + onSelect: () => { this._view.popTo(1); this._pushDevicesColumn(null, 'audio'); } } - }]; - + ]; this._view.pushColumn('Studio', items, { searchable: false }); } - _pushGroupsColumn() { - const items = []; + // ── Eşler ───────────────────────────────────────────────────────────────── - for (const [id, room] of this.mwse.rooms) { + _pushPeersColumn() { + const pairs = [...this.mwse.pairs.values()]; + const items = pairs.map(peer => ({ + icon: peer.peerConnection ? '◉' : '●', + label: peer.socketId, + meta: () => this._peerMeta(peer), + onSelect: () => { this._view.popTo(2); this._pushPeerColumn(peer); } + })); + + if (!items.length) { items.push({ - icon: '#', - label: id, - meta: () => `${room.peers.size} peers`, - onSelect: () => { - this._view.popTo(2); - this._pushPeersColumn(room); - } + icon: '—', label: 'Henüz eş yok', + meta: 'Başka bir istemci bağlanınca görünür', hasChildren: false }); } - if (items.length === 0) { - items.push({ icon: '—', label: 'No groups', meta: 'Join a room first', hasChildren: false }); - } - - this._view.pushColumn('Groups', items); + const col = this._view.pushColumn('Eşler', items); + col.addAction('ID ile ara', '', () => { + const id = prompt('Eş socket ID:'); + if (!id?.trim()) return; + this.mwse.peer(id.trim()).requestPair() + .then(() => this._setStatus('', `${id.slice(-8)}'e istek gönderildi`)) + .catch(e => this._setStatus('error', e.message)); + }); } - _pushPeersColumn(room) { - const items = []; - - for (const [, peer] of room.peers) { - items.push({ - icon: peer.selfSocket ? '★' : '●', - label: peer.socketId, - meta: () => peer.peerConnection ? 'p2p' : 'ws', - onSelect: () => { - this._view.popTo(3); - this._pushDevicesColumn(peer); - } - }); - } - - if (items.length === 0) { - items.push({ icon: '—', label: 'No peers in room', meta: '', hasChildren: false }); - } - - this._view.pushColumn('Peers', items); + _peerMeta(peer) { + const streams = peer.rtc?._streams?.list() ?? []; + if (streams.length) return `p2p · ${streams.length} akış aktif`; + if (peer.rtc?.active) return `p2p · ${peer.rtc.connectionStatus}`; + return 'websocket'; } - _pushDevicesColumn(peer) { + // ── Eş eylem kolonu ─────────────────────────────────────────────────────── + + _pushPeerColumn(peer) { + const streams = peer.rtc?._streams?.list() ?? []; + const rtcOn = peer.rtc?.active; + const items = [ { - icon: '◎', - label: 'Camera', - meta: 'Capture video', - onSelect: () => this._openCamera(peer) + icon: '▶', + label: 'Video + Ses', + meta: 'Kamera ve mikrofon', + onSelect: () => this._call(peer, 'cam+mic') }, { - icon: '●', - label: 'Camera + Mic', - meta: 'Video + audio', - onSelect: () => this._openCameraAndMic(peer) + icon: '♪', + label: 'Sesli Ara', + meta: 'Yalnızca mikrofon', + onSelect: () => this._call(peer, 'mic') }, { - icon: '♪', - label: 'Microphone', - meta: 'Audio only', - onSelect: () => this._openMicrophone(peer) + icon: '⬜', + label: 'Ekran Paylaş', + meta: 'getDisplayMedia', + onSelect: () => this._call(peer, 'screen') }, { - icon: '⬜', - label: 'Screen', - meta: 'Share display', - onSelect: () => this._openScreen(peer) + icon: '◎', + label: 'Kamera Seç', + meta: `${this._devices.cameras.length} kamera`, + onSelect: () => { this._view.popTo(3); this._pushDevicesColumn(peer, 'video'); } }, { - icon: '↗', - label: 'Send file', - meta: 'DataChannel transfer', + icon: '♪', + label: 'Mikrofon Seç', + meta: `${this._devices.microphones.length} mikrofon`, + onSelect: () => { this._view.popTo(3); this._pushDevicesColumn(peer, 'audio'); } + }, + { + icon: '↗', + label: 'Dosya Gönder', + meta: 'P2P DataChannel', hasChildren: false, - onSelect: () => this._openFilePicker(peer) + onSelect: () => this._sendFile(peer) } ]; - const col = this._view.pushColumn('Devices', items); - - // Show already-active streams at the bottom of devices. - if (peer.rtc?._streams) { - const active = peer.rtc._streams.list(); - if (active.length) { - col.addAction('View streams', '', () => { - this._view.popTo(4); - this._pushActiveStreamsColumn(peer); - }); - } + if (streams.length) { + items.push({ + icon: '◉', + label: `Aktif Akışlar (${streams.length})`, + meta: streams.map(s => s.label).join(' · '), + onSelect: () => { this._view.popTo(3); this._pushStreamsColumn(peer); } + }); } + + if (rtcOn) { + items.push({ + icon: '↺', + label: 'WebRTC Yeniden Başlat', + meta: '', + hasChildren: false, + onSelect: () => { peer.rtc.destroy(); this._ensureRTC(peer); this._view.refresh(); } + }); + } + + items.push({ + icon: '⊘', + label: 'Eşleşmeyi Bitir', + meta: '', + hasChildren: false, + onSelect: async () => { + await peer.endPair().catch(() => {}); + this._view.popTo(1); + this._pushPeersColumn(); + } + }); + + this._view.pushColumn(peer.socketId.slice(-12), items, { searchable: false }); } - _pushActiveStreamsColumn(peer) { - const srcs = peer.rtc?._streams?.list() ?? []; - const items = srcs.map(src => ({ - icon: src.tracks[0]?.kind === 'video' ? '▶' : '♪', - label: src.label, - meta: src.tracks.map(t => t.kind).join(' + '), - onSelect: () => { - this._view.popTo(5); - this._pushQualityColumn(peer, src.label, src); + // ── Odalar ─────────────────────────────────────────────────────────────── + + _pushRoomsColumn() { + const items = [...this.mwse.rooms.entries()].map(([id, room]) => ({ + icon: '#', + label: id, + meta: () => `${room.peers.size} üye`, + onSelect: () => { this._view.popTo(2); this._pushRoomMembersColumn(room, id); } + })); + + if (!items.length) { + items.push({ + icon: '—', label: 'Oda yok', + meta: 'room.createRoom() ile oluştur', hasChildren: false + }); + } + + this._view.pushColumn('Odalar', items); + } + + _pushRoomMembersColumn(room, roomId) { + const items = [...room.peers.values()].map(peer => ({ + icon: peer.selfSocket ? '★' : '●', + label: peer.socketId, + meta: () => this._peerMeta(peer), + onSelect: () => { this._view.popTo(3); this._pushPeerColumn(peer); } + })); + + if (!items.length) { + items.push({ icon: '—', label: 'Üye yok', meta: '', hasChildren: false }); + } + + this._view.pushColumn(roomId, items); + } + + // ── Cihazlar ───────────────────────────────────────────────────────────── + + _pushDevicesColumn(peer, kind) { + const list = kind === 'audio' + ? this._devices.microphones + : this._devices.cameras; + + const colTitle = kind === 'audio' ? 'Mikrofonlar' : 'Kameralar'; + + const items = list.map(dev => ({ + icon: kind === 'audio' ? '♪' : '◎', + 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 getter = kind === 'audio' + ? MediaSources.microphone(constraints) + : MediaSources.camera(constraints); + + const stream = await getter.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); + peer.rtc.addStream(label, stream); + this._view.popTo(4); + this._pushStreamsColumn(peer); + } else { + this._previewStream(stream, dev.label || colTitle); + } } })); - this._view.pushColumn('Streams', items); + + if (!items.length) { + items.push({ + icon: '—', label: 'Cihaz bulunamadı', + meta: 'İzin ver ve sayfayı yenile', hasChildren: false + }); + } + + const col = this._view.pushColumn(colTitle, items); + + // İzin isteme butonu + col.addAction('İ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 ────────────────────────────────────────────────────────────── + + _pushStreamsColumn(peer) { + const srcs = peer.rtc?._streams?.list() ?? []; + const items = srcs.map(src => ({ + icon: src.tracks[0]?.kind === 'video' ? '▶' : '♪', + 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); + } + + // ── Kalite / kontrol ───────────────────────────────────────────────────── + _pushQualityColumn(peer, label, src) { const presets = [ - { icon: '↑', label: 'High', meta: '1080p · 4 Mbps', params: { maxBitrate: 4_000_000, scaleResolutionDownBy: 1 } }, - { icon: '—', label: 'Medium', meta: '720p · 1.5 Mbps', params: { maxBitrate: 1_500_000, scaleResolutionDownBy: 1.5 } }, - { icon: '↓', label: 'Low', meta: '480p · 500 Kbps', params: { maxBitrate: 500_000, scaleResolutionDownBy: 2 } }, + { icon: '↑', label: 'Yüksek', meta: '1080p · 4 Mbps', params: { maxBitrate: 4_000_000, scaleResolutionDownBy: 1 } }, + { icon: '—', label: 'Orta', meta: '720p · 1.5 Mbps', params: { maxBitrate: 1_500_000, scaleResolutionDownBy: 1.5 } }, + { icon: '↓', 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) + icon: p.icon, label: p.label, meta: p.meta, hasChildren: false, + onSelect: () => peer.rtc?.setEncodings(label, 'video', p.params) })); - // Mute / stop controls. for (const track of (src.tracks ?? [])) { items.push({ - icon: track.enabled ? '⊙' : '○', - label: `${track.enabled ? 'Mute' : 'Unmute'} ${track.kind}`, - meta: '', - hasChildren: false, - onSelect: () => { + icon: track.enabled ? '⊙' : '○', + 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(); } @@ -207,102 +373,138 @@ export default class Studio { } items.push({ - icon: '✕', - label: 'Stop stream', - meta: '', - hasChildren: false, - onSelect: () => { + icon: '✕', label: 'Akışı Durdur', meta: '', hasChildren: false, + onSelect: () => { peer.rtc?.removeStream(label); - this._view.popTo(4); + this._view.popTo(3); + this._pushStreamsColumn(peer); } }); - this._view.pushColumn('Quality', items); + this._view.pushColumn('Kalite', items, { searchable: false }); } - // ---- Device helpers ------------------------------------------------- + // ── WebRTC yardımcıları ─────────────────────────────────────────────────── - async _openCamera(peer) { - const stream = await MediaSources.camera().catch(e => { this._setStatus('error', e.message); return null; }); - if (!stream) return; + async _call(peer, type) { this._ensureRTC(peer); - peer.rtc.addStream('camera', stream); - this._view.popTo(4); - this._pushActiveStreamsColumn(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; + } + peer.rtc.addStream(type, stream); + this._setStatus('online', `${type} → ${peer.socketId.slice(-8)}`); + this._view.popTo(3); + this._pushStreamsColumn(peer); } - async _openCameraAndMic(peer) { - const stream = await MediaSources.cameraAndMic().catch(e => { this._setStatus('error', e.message); return null; }); - if (!stream) return; + _sendFile(peer) { this._ensureRTC(peer); - peer.rtc.addStream('cam+mic', stream); - this._view.popTo(4); - this._pushActiveStreamsColumn(peer); - } - - async _openMicrophone(peer) { - const stream = await MediaSources.microphone().catch(e => { this._setStatus('error', e.message); return null; }); - if (!stream) return; - this._ensureRTC(peer); - peer.rtc.addStream('mic', stream); - this._view.popTo(4); - this._pushActiveStreamsColumn(peer); - } - - async _openScreen(peer) { - const stream = await MediaSources.screen().catch(e => { this._setStatus('error', e.message); return null; }); - if (!stream) return; - this._ensureRTC(peer); - peer.rtc.addStream('screen', stream); - this._view.popTo(4); - this._pushActiveStreamsColumn(peer); - } - - _openFilePicker(peer) { const input = document.createElement('input'); input.type = 'file'; input.addEventListener('change', async () => { const file = input.files?.[0]; if (!file) return; - this._ensureRTC(peer); - this._setStatus('', `Sending ${file.name}…`); - peer.rtc.files.on('progress', ({ sent, total }) => { - const pct = Math.round(sent / total * 100); - this._setStatus('', `${file.name} — ${pct}%`); - }); + 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} sent`); + this._setStatus('online', `${file.name} gönderildi`); }); input.click(); } - // ---- RTCEngine management ------------------------------------------- - - // Ensure the peer has an RTCEngine connected (creates one if absent). _ensureRTC(peer) { - if (peer.rtc.active) return; - - // Determine politeness by lexicographic socket ID comparison. - const myId = this.mwse.me.socketId; - const polite = myId < peer.socketId; - + if (peer.rtc?._pc) return; + const polite = this.mwse.me.socketId < peer.socketId; peer.rtc.connect({ polite }); } - // ---- Helpers -------------------------------------------------------- + // Gelen track'leri otomatik çal + _watchIncoming(peer) { + peer.rtc.on('track', track => { + if (track.kind === 'audio') { + const audio = new Audio(); + audio.autoplay = true; + audio.srcObject = new MediaStream([track]); + document.body.appendChild(audio); + this._remoteMedia.push(audio); + this._setStatus('online', `← ${peer.socketId.slice(-8)} ses gönderdi`); + } else { + this._setStatus('online', `← ${peer.socketId.slice(-8)} video gönderdi`); + } + }); + } + + // Cihaz test önizlemesi (floating video) + _previewStream(stream, label) { + const old = document.getElementById('mwse-preview'); + if (old) old.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 closeBtn = document.createElement('span'); + closeBtn.textContent = '✕'; + closeBtn.style.cursor = 'pointer'; + closeBtn.addEventListener('click', () => { + stream.getTracks().forEach(t => t.stop()); + wrap.remove(); + }); + bar.append(document.createTextNode(label), closeBtn); + + 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); + } + + // ── Cihaz yükleme ───────────────────────────────────────────────────────── + + async _loadDevices() { + try { + this._devices = await MediaSources.devices(); + } catch (_) { + this._devices = { cameras: [], microphones: [] }; + } + } + + // ── Durum çubuğu ───────────────────────────────────────────────────────── _setStatus(cls, text) { if (!this._statusEl) return; - this._statusEl.className = `mwse-studio__status${cls ? ` mwse-studio__status--${cls}` : ''}`; + this._statusEl.className = [ + 'mwse-studio__status', + cls ? `mwse-studio__status--${cls}` : '' + ].join(' ').trim(); this._statusEl.textContent = text; } _injectStyle() { - if (this._styleInjected) return; + if (this._styleOK) return; const link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = new URL('./style.css', import.meta.url).href; + link.rel = 'stylesheet'; + link.href = '/studio/style.css'; document.head.appendChild(link); - this._styleInjected = true; + this._styleOK = true; } } diff --git a/public/studio/app.js b/public/studio/app.js index c71510f..3d11c22 100644 --- a/public/studio/app.js +++ b/public/studio/app.js @@ -1,15 +1,16 @@ import MWSE from '/sdk/index.js'; import Studio from '/studio/Studio.js'; -const mwse = new MWSE(); -const studio = new Studio(mwse, document.getElementById('app')); - const loadingEl = document.getElementById('loading'); const loadingMsg = document.getElementById('loading-msg'); +const appEl = document.getElementById('app'); -mwse.on('scope', () => { +const mwse = new MWSE(); // endpoint: otomatik — aynı sunucu +const studio = new Studio(mwse, appEl); + +mwse.on('scope', async () => { loadingEl.classList.add('hidden'); - studio.mount(); + await studio.mount(); }); mwse.on('close', () => { @@ -18,6 +19,7 @@ mwse.on('close', () => { }); 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'); }); diff --git a/sdk/index.js b/sdk/index.js index 3e69d15..713c080 100644 --- a/sdk/index.js +++ b/sdk/index.js @@ -35,7 +35,11 @@ export default class MWSE { this._events = {}; this.activeScope = false; - this.server = new Connection(this, options); + // Default endpoint to 'auto' (SDK reads import.meta.url → same origin). + const opts = typeof options === 'string' + ? { endpoint: options } + : { endpoint: 'auto', ...options }; + this.server = new Connection(this, opts); this.WSTSProtocol = new WSTSProtocol(this); this.EventPooling = new EventPool(this); this.virtualPressure = new IPPressure(this);