checkpoint, switching to node native typescript

+51
src/client/components/messenger.jsx
··· 1 + // @ts-nocheck 2 + 3 + import * as connection_types from '#client/realm/connection.js' 4 + import * as protocol_types from '#common/protocol.js' 5 + import { useState, useEffect, useCallback } from 'preact/hooks' 6 + 7 + /** 8 + * @param {object} props list props 9 + * @param {connection_types.RealmConnection} props.webrtcManager connection manager 10 + * @returns {preact.JSX.Element} the peerlist component 11 + */ 12 + export function Messenger({ webrtcManager }) { 13 + const [messages, setMessages] = useState(/** @type {Array<any>} */ []) 14 + 15 + const peerdata = useCallback( 16 + /** @type {function(CustomEvent): void} */ 17 + (event) => { 18 + setMessages([...messages, [event.detail.remoteId, event.detail.data]]) 19 + }, 20 + [messages], 21 + ) 22 + 23 + const sendMessage = useCallback( 24 + () => { 25 + webrtcManager.broadcast('what\'s up friends?') 26 + }, 27 + [webrtcManager], 28 + ) 29 + 30 + useEffect(() => { 31 + if (!webrtcManager) return 32 + 33 + webrtcManager.addEventListener('peerdata', peerdata) 34 + return () => { 35 + webrtcManager.removeEventListener('peerdata', peerdata) 36 + } 37 + }, [webrtcManager, peerdata]) 38 + 39 + return ( 40 + <div className="messages-list"> 41 + <h3>Realm Messages</h3> 42 + <pre> 43 + { JSON.stringify(messages, null, 2) } 44 + </pre> 45 + <div> 46 + <textarea /> 47 + <button onClick={sendMessage}>Send</button> 48 + </div> 49 + </div> 50 + ) 51 + }
+89
src/client/components/peer-list.jsx
··· 1 + import * as connection_types from '#client/realm/connection.js' 2 + import * as protocol_types from '#common/protocol.js' 3 + import { useState, useEffect } from 'preact/hooks' 4 + 5 + /** 6 + * @param {object} props list props 7 + * @param {connection_types.RealmConnection} props.webrtcManager connection manager 8 + * @returns {preact.JSX.Element} the peerlist component 9 + */ 10 + export function PeerList({ webrtcManager }) { 11 + const [peers, setPeers] = useState( 12 + /** @type {Record<protocol_types.IdentID, connection_types.PeerState>} */ ({}), 13 + ) 14 + 15 + useEffect(() => { 16 + if (!webrtcManager) return 17 + 18 + const updatePeers = () => { 19 + queueMicrotask(() => { 20 + const states = webrtcManager.getPeerStates() 21 + console.log('updating peers', states) 22 + setPeers(states) 23 + }) 24 + } 25 + 26 + // Listen to WebRTC events 27 + const events = ['peeropen', 'peerclose'] 28 + 29 + events.forEach((event) => { 30 + webrtcManager.addEventListener(event, updatePeers) 31 + }) 32 + 33 + // Initial state 34 + updatePeers() 35 + 36 + return () => { 37 + events.forEach((event) => { 38 + webrtcManager.removeEventListener(event, updatePeers) 39 + }) 40 + } 41 + }, [webrtcManager]) 42 + 43 + return ( 44 + <div className="peer-list"> 45 + <h3>Realm Members</h3> 46 + { 47 + Object.keys(peers).length === 0 48 + ? <p>No other peers connected</p> 49 + : ( 50 + <ul> 51 + {Object.entries(peers).map(([peerId, state]) => ( 52 + <li key={peerId} className={`peer-item ${state.state}`}> 53 + <span className="peer-id">{peerId}</span> 54 + <ConnectionStatus state={state} /> 55 + </li> 56 + ))} 57 + </ul> 58 + ) 59 + } 60 + </div> 61 + ) 62 + } 63 + 64 + /** 65 + * @private 66 + * @param {object} props status props 67 + * @param {connection_types.PeerState} props.state current state 68 + * @returns {preact.JSX.Element} the status component 69 + */ 70 + function ConnectionStatus({ state }) { 71 + const getStatusIcon = () => { 72 + if (state.connected) return '🟢' 73 + if (state.destroyed) return '🔴' 74 + return '🟡' 75 + } 76 + 77 + return ( 78 + <div className="connection-status"> 79 + <span className="status-icon">{getStatusIcon()}</span> 80 + <code className="initiator"> 81 + {state.address.family} 82 + - 83 + {state.address.address} 84 + : 85 + {state.address.port} 86 + </code> 87 + </div> 88 + ) 89 + }
-68
src/client/index.css
··· 1 - :root { 2 - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; 3 - line-height: 1.5; 4 - font-weight: 400; 5 - 6 - color-scheme: light dark; 7 - color: rgba(255, 255, 255, 0.87); 8 - background-color: #242424; 9 - 10 - font-synthesis: none; 11 - text-rendering: optimizeLegibility; 12 - -webkit-font-smoothing: antialiased; 13 - -moz-osx-font-smoothing: grayscale; 14 - } 15 - 16 - a { 17 - font-weight: 500; 18 - color: #646cff; 19 - text-decoration: inherit; 20 - } 21 - a:hover { 22 - color: #535bf2; 23 - } 24 - 25 - body { 26 - margin: 0; 27 - display: flex; 28 - place-items: center; 29 - min-width: 320px; 30 - min-height: 100vh; 31 - } 32 - 33 - h1 { 34 - font-size: 3.2em; 35 - line-height: 1.1; 36 - } 37 - 38 - button { 39 - border-radius: 8px; 40 - border: 1px solid transparent; 41 - padding: 0.6em 1.2em; 42 - font-size: 1em; 43 - font-weight: 500; 44 - font-family: inherit; 45 - background-color: #1a1a1a; 46 - cursor: pointer; 47 - transition: border-color 0.25s; 48 - } 49 - button:hover { 50 - border-color: #646cff; 51 - } 52 - button:focus, 53 - button:focus-visible { 54 - outline: 4px auto -webkit-focus-ring-color; 55 - } 56 - 57 - @media (prefers-color-scheme: light) { 58 - :root { 59 - color: #213547; 60 - background-color: #ffffff; 61 - } 62 - a:hover { 63 - color: #747bff; 64 - } 65 - button { 66 - background-color: #f9f9f9; 67 - } 68 - }
+5 -5
src/client/page-app.jsx
··· 1 - import './page-app.css' 1 + import { RealmConnectionProvider } from './realm/context.jsx' 2 + import { WebRTCDemo } from './webrtc-demo.jsx' 2 3 3 4 /** 4 5 * @returns {preact.JSX.Element} app wrapper component 5 6 */ 6 7 export function App() { 7 8 return ( 8 - <> 9 - <h1>whatever</h1> 10 - <h2>and, how!</h2> 11 - </> 9 + <RealmConnectionProvider url="ws://localhost:3001/stream"> 10 + <WebRTCDemo /> 11 + </RealmConnectionProvider> 12 12 ) 13 13 }
+1 -1
src/client/page-app.spec.jsx
··· 1 - /** @jest-environment jsdom */ 1 + /** @jest-environment jest-fixed-jsdom */ 2 2 3 3 import { describe, it, expect } from '@jest/globals' 4 4
+425
src/client/realm/connection.js
··· 1 + /** @module client/realm */ 2 + 3 + import { nanoid } from 'nanoid' 4 + import SimplePeer from 'simple-peer' 5 + 6 + import { generateSignableJwt, jwkExport } from '#common/crypto/jwks.js' 7 + import { normalizeError, normalizeProtocolError, ProtocolError } from '#common/errors.js' 8 + import { parseJson, realmFromServerMessageSchema, realmRtcPeerWelcomeMessageSchema } from '#common/protocol.js' 9 + import { sendSocket, streamSocket, streamSocketJson, takeSocketJson } from '#common/socket.js' 10 + 11 + import * as protocol_types from '#common/protocol.js' 12 + 13 + /** 14 + * @typedef {object} PeerState 15 + * @property {ReturnType<SimplePeer.Instance['address']>} address peer address 16 + * @property {boolean} connected true if the peer connection is active 17 + * @property {boolean} destroyed true if the peer connection has been destroyed 18 + * @property {string} state a simple status string 19 + */ 20 + 21 + /** 22 + * @typedef {object} RealmIdentity 23 + * @property {protocol_types.RealmID} realmid 24 + * @property {protocol_types.IdentID} identid 25 + * @property {CryptoKeyPair} keypair 26 + */ 27 + 28 + /** 29 + * A connection manager 30 + */ 31 + export class RealmConnection extends EventTarget { 32 + 33 + /** @type {string} */ 34 + #url 35 + 36 + /** @type {RealmIdentity} */ 37 + #identity 38 + 39 + /** @type {WebSocket} */ 40 + #socket 41 + 42 + /** @type {Map<protocol_types.IdentID, SimplePeer.Instance>} */ 43 + #peers 44 + 45 + /** @type {Map<protocol_types.IdentID, string>} */ 46 + #nonces 47 + 48 + /** 49 + * @param {string} url the realm server we're connecting to 50 + * @param {RealmIdentity} identity this connection's realm identity 51 + */ 52 + constructor(url, identity) { 53 + super() 54 + 55 + this.#url = url 56 + this.#identity = identity 57 + 58 + this.#peers = new Map() 59 + this.#nonces = new Map() 60 + 61 + this.#socket = new WebSocket(this.#url) 62 + this.#socket.onopen = this.#handleSocketOpen 63 + this.#socket.onclose = this.#handleSocketClose 64 + this.#socket.onerror = this.#handleSocketError 65 + } 66 + 67 + /** @type {WebSocket['onopen']} */ 68 + #handleSocketOpen = async () => { 69 + if (this.#socket == undefined) 70 + throw new Error('socket open handler called with no socket?') 71 + 72 + try { 73 + console.debug('realm connection, socket loop open') 74 + this.#dispatchCustomEvent('wsopen') 75 + 76 + // do the auth dance 77 + // TODO: this should be a state machine 78 + 79 + const pubkey = await jwkExport.parseAsync(this.#identity.keypair.publicKey) 80 + this.#socket.send( 81 + await this.#signJwt( 82 + /** @type {protocol_types.PreauthRegisterMessage} */ 83 + ({ msg: 'preauth.register', pubkey }), 84 + ), 85 + ) 86 + 87 + // the next message should be a welcome message 88 + // this will throw (authenticated) otherwise 89 + 90 + /** @type {protocol_types.RealmRtcPeerWelcomeMessage} */ 91 + let welcome 92 + try { 93 + welcome = await takeSocketJson(this.#socket, realmRtcPeerWelcomeMessageSchema) 94 + } 95 + catch (exc) { 96 + const err = normalizeError(exc) 97 + throw new ProtocolError('failure on authentication', 401, { cause: err }) 98 + } 99 + 100 + this.#dispatchCustomEvent('wsauth', { welcome }) 101 + 102 + // we initiate connections outbound when starting up 103 + // we never initiate connections to other peers otherwise 104 + 105 + for (const peerid of welcome.peers) { 106 + if (peerid === this.#identity.identid) continue 107 + this.connectToPeer(peerid, true) 108 + } 109 + 110 + // then continue looping over messages on the socket and handle them 111 + 112 + for await (const data of streamSocketJson(this.#socket)) { 113 + const parse = realmFromServerMessageSchema.safeParse(data) 114 + 115 + // not a known rtc specific message, so we don't capture it 116 + if (!parse.success) { 117 + this.#dispatchCustomEvent('wsdata', { data }) 118 + continue 119 + } 120 + 121 + // otherwise, we handle it 122 + switch (parse.data.msg) { 123 + case 'realm.rtc.peer-joined': 124 + // new peers connect to us, we don't connect to them 125 + this.#dispatchCustomEvent('peerjoined', { identid: parse.data.identid }) 126 + continue 127 + 128 + case 'realm.rtc.peer-left': 129 + // peer is gone, disconnect 130 + this.disconnectPeer(parse.data.identid) 131 + this.#dispatchCustomEvent('peerleft', { identid: parse.data.identid }) 132 + continue 133 + 134 + case 'realm.rtc.signal': { 135 + // some other peer is trying to send us some rtc data 136 + // only connect if _they_ are the initiator - our initiating conns were above 137 + let peer = this.#peers.get(parse.data.sender) 138 + if (!peer && parse.data.initiator) { 139 + peer = this.connectToPeer(parse.data.sender, false) 140 + } 141 + 142 + // may not have a connection yet if we're waiting for them to answer 143 + if (peer) { 144 + peer.signal(JSON.parse(parse.data.payload)) 145 + } 146 + 147 + continue 148 + } 149 + } 150 + } 151 + } 152 + catch (exc) { 153 + const err = normalizeProtocolError(exc) 154 + 155 + console.error('realm connection, socket loop error', err) 156 + this.#dispatchCustomEvent('wserror', { error: err }) 157 + } 158 + finally { 159 + console.debug('realm connection, socket loop ended') 160 + this.destroy() 161 + } 162 + } 163 + 164 + /** @type {WebSocket['onerror']} */ 165 + #handleSocketError = async (exc) => { 166 + this.#dispatchCustomEvent('wserror', { error: normalizeProtocolError(exc) }) 167 + this.destroy() 168 + } 169 + 170 + /** @type {WebSocket['onclose']} */ 171 + #handleSocketClose = async () => { 172 + this.#dispatchCustomEvent('wsclose') 173 + this.destroy() 174 + } 175 + 176 + get connected() { 177 + return this.#socket.readyState === this.#socket.OPEN 178 + } 179 + 180 + destroy() { 181 + console.debug('realm connection #destroy!') 182 + 183 + if (this.connected) 184 + this.#socket.close() 185 + 186 + for (const peer of this.#peers.values()) 187 + peer.destroy() 188 + 189 + this.#peers.clear() 190 + this.#nonces.clear() 191 + } 192 + 193 + /** 194 + * dispatch a `CustomEvent` 195 + * 196 + * @param {string} type the event type (for addEVentListener(type)) 197 + * @param {object} [detail] the details object to include 198 + */ 199 + #dispatchCustomEvent(type, detail) { 200 + this.dispatchEvent(new CustomEvent(type, { detail })) 201 + } 202 + 203 + /** 204 + * generates and signs a JWT scoped to this identity/realm containing the given payload 205 + * 206 + * @param {object} payload the payload to embed in the JWT 207 + * @returns {Promise<string>} a signed JWT for sending upstream 208 + */ 209 + async #signJwt(payload) { 210 + return await generateSignableJwt({ 211 + aud: this.#identity.realmid, 212 + iss: this.#identity.identid, 213 + payload, 214 + }).sign(this.#identity.keypair.privateKey) 215 + } 216 + 217 + /** 218 + * @private 219 + * @param {protocol_types.IdentID} remoteid the identity we're connecting to 220 + * @param {boolean} initiator are we the initiator of this connection? 221 + * @returns {SimplePeer.Instance} the configured peer for the identid 222 + */ 223 + connectToPeer(remoteid, initiator) { 224 + let peer = this.#peers.get(remoteid) 225 + if (peer) { 226 + console.log(`already connected to ${remoteid}`) 227 + return peer 228 + } 229 + 230 + peer = new RealmConnectionPeer( 231 + this, 232 + nanoid(), 233 + this.#identity.identid, 234 + remoteid, 235 + initiator, 236 + ) 237 + 238 + peer.on('connect', () => { 239 + console.log(`connected to ${remoteid}`) 240 + 241 + this.#dispatchCustomEvent('peeropen', { remoteid }) 242 + }) 243 + 244 + peer.on('close', () => { 245 + console.log(`Disconnected from ${remoteid}`) 246 + 247 + this.#peers.delete(remoteid) 248 + this.#nonces.delete(remoteid) 249 + this.#dispatchCustomEvent('peerclose', { remoteid }) 250 + }) 251 + 252 + peer.on('error', (err) => { 253 + console.error(`Error with peer ${remoteid}:`, err) 254 + 255 + this.#dispatchCustomEvent('peererror', { remoteid, error: err }) 256 + }) 257 + 258 + peer.on('message', (data) => { 259 + this.#dispatchCustomEvent('peerdata', { remoteid, data }) 260 + }) 261 + 262 + this.#peers.set(remoteid, peer) 263 + return peer 264 + } 265 + 266 + /** @param {protocol_types.IdentID} identid the recipient to disconnect from */ 267 + disconnectPeer(identid) { 268 + const peer = this.#peers.get(identid) 269 + if (peer) { 270 + peer.destroy() 271 + this.#peers.delete(identid) 272 + this.#nonces.delete(identid) 273 + } 274 + } 275 + 276 + /** 277 + * @param {protocol_types.IdentID} identid the recipient to send data to 278 + * @param {any} data the data to send via peer connection 279 + */ 280 + sendToPeer(identid, data) { 281 + const peer = this.#peers.get(identid) 282 + if (peer && peer.connected) { 283 + peer.send(JSON.stringify(data)) 284 + } 285 + else { 286 + throw new Error(`Not connected to peer: ${identid}`) 287 + } 288 + } 289 + 290 + /** 291 + * @param {unknown} data to send along the socket to the realm server 292 + */ 293 + sendToServer(data) { 294 + sendSocket(this.#socket, data) 295 + } 296 + 297 + /** @param {any} data the data to send via peer connection */ 298 + broadcast(data) { 299 + const message = JSON.stringify(data) 300 + for (const [_, peer] of this.#peers) { 301 + if (peer.connected) { 302 + peer.send(message) 303 + } 304 + } 305 + } 306 + 307 + /** @param {any} data the data to send via server */ 308 + broadcastViaServer(data) { 309 + /** @type {protocol_types.RealmBroadcastMessage} */ 310 + const resp = { 311 + msg: 'realm.broadcast', 312 + payload: data, 313 + recipients: false, 314 + } 315 + sendSocket(this.#socket, resp) 316 + } 317 + 318 + /** 319 + * @returns {Record<protocol_types.IdentID, PeerState>} the current peer state mapping 320 + */ 321 + getPeerStates() { 322 + /** @type {Record<protocol_types.IdentID, PeerState>} */ 323 + const states = {} 324 + 325 + for (const [peerId, peer] of this.#peers) { 326 + states[peerId] = { 327 + address: peer.address(), 328 + connected: peer.connected, 329 + destroyed: peer.destroyed, 330 + state: peer.connected ? 'connected' : 'disconnected', 331 + } 332 + } 333 + 334 + return states 335 + } 336 + 337 + } 338 + 339 + /** 340 + * a peer belonging to the connection manager 341 + */ 342 + export class RealmConnectionPeer extends SimplePeer { 343 + 344 + /** @type {RealmConnection} */ 345 + #connection 346 + 347 + /** @type {boolean} */ 348 + initiator 349 + 350 + /** @type {protocol_types.IdentID} */ 351 + localid 352 + 353 + /** @type {protocol_types.IdentID} */ 354 + remoteid 355 + 356 + /** @type {string} */ 357 + nonce 358 + 359 + /** 360 + * @param {RealmConnection} connection the connection that owns this peer 361 + * @param {string} nonce a uniquely identifying string for this peer 362 + * @param {protocol_types.IdentID} localid the local identity 363 + * @param {protocol_types.IdentID} remoteid the remote identity 364 + * @param {boolean} initiator whether we're initiating the connection 365 + */ 366 + constructor(connection, nonce, localid, remoteid, initiator) { 367 + super({ 368 + initiator, 369 + config: { 370 + iceServers: [ 371 + { urls: 'stun:stun.l.google.com:19302' }, 372 + { urls: 'stun:stun1.l.google.com:19302' }, 373 + ], 374 + }, 375 + }) 376 + 377 + this.initiator = initiator 378 + this.localid = localid 379 + this.remoteid = remoteid 380 + this.nonce = nonce 381 + 382 + this.#connection = connection 383 + 384 + this.on('signal', this.#handlePeerSignal) 385 + this.on('data', this.#handlePeerData) 386 + } 387 + 388 + /** @type {function(SimplePeer.SignalData): void} */ 389 + #handlePeerSignal = (e) => { 390 + this.#connection.sendToServer( 391 + /** @type {protocol_types.RealmRtcSignalMessage} */ 392 + ({ 393 + msg: 'realm.rtc.signal', 394 + initiator: this.initiator, 395 + sender: this.localid, 396 + recipient: this.remoteid, 397 + payload: JSON.stringify(e), 398 + }), 399 + ) 400 + } 401 + 402 + /** @type {function(string): void} */ 403 + #handlePeerData = (chunk) => { 404 + try { 405 + const parsed = JSON.parse(chunk) 406 + switch (parsed.type) { 407 + // there are some connection-manager internal messages 408 + case 'pong': 409 + this.send( 410 + JSON.stringify({ type: 'pong', timestamp: parsed.timestamp }), 411 + ) 412 + break 413 + 414 + // dispatch others messages 415 + default: 416 + this.emit('message', parsed) 417 + break 418 + } 419 + } 420 + catch (err) { 421 + console.error('Failed to parse message:', err) 422 + } 423 + } 424 + 425 + }
+75
src/client/realm/context.jsx
··· 1 + import { createContext } from 'preact' 2 + import * as preact_types from 'preact' 3 + import * as connection_types from '#client/realm/connection.js' 4 + 5 + import { RealmConnection } from '#client/realm/connection.js' 6 + import { useCallback, useEffect, useState } from 'preact/hooks' 7 + 8 + /** 9 + * @typedef {object} RealmConnectionContext 10 + * @property {RealmConnection | null} realm the realm server we're connected to 11 + * @property {connection_types.RealmIdentity | null} identity the realm identity we're connected as 12 + * @property {function(connection_types.RealmIdentity): void} setIdentity 13 + * a callback to set the current identity 14 + */ 15 + 16 + export const RealmConnectionContext 17 + = createContext(/** @type {RealmConnectionContext | null} */ (null)) 18 + 19 + /** 20 + * @typedef {object} RealmConnectionProviderProps 21 + * @property {string} url the realm server to connect to 22 + * @property {preact_types.ComponentChildren} children the children to render 23 + */ 24 + 25 + /** 26 + * @type {preact.FunctionComponent<RealmConnectionProviderProps>} 27 + */ 28 + export const RealmConnectionProvider = (props) => { 29 + const [identity$, setIdentity$] 30 + = useState(/** @type {connection_types.RealmIdentity | null} */ (null)) 31 + 32 + const [connection$, setConnection$] 33 + = useState(/** @type {RealmConnection | null} */ (null)) 34 + 35 + const connect = useCallback( 36 + () => { 37 + if (connection$) return 38 + if (!identity$) return 39 + 40 + const connection = new RealmConnection(props.url, identity$) 41 + setConnection$(connection) 42 + }, 43 + [connection$, identity$, props.url], 44 + ) 45 + 46 + const disconnect = useCallback( 47 + () => { 48 + connection$?.destroy() 49 + }, 50 + [connection$], 51 + ) 52 + 53 + useEffect( 54 + () => { 55 + console.log('use effect in provider', identity$) 56 + // connect on mount, or identity change 57 + if (identity$) connect() 58 + 59 + // disconnect on unsubscribe 60 + return disconnect 61 + }, 62 + [connect, disconnect, identity$], 63 + ) 64 + 65 + return ( 66 + <RealmConnectionContext.Provider 67 + children={props.children} 68 + value={{ 69 + realm: connection$, 70 + identity: identity$, 71 + setIdentity: setIdentity$, 72 + }} 73 + /> 74 + ) 75 + }
+147
src/client/webrtc-demo.css
··· 1 + .webrtc-demo { 2 + padding: 20px; 3 + max-width: 1200px; 4 + margin: 0 auto; 5 + } 6 + 7 + .connection-info { 8 + background: #f0f0f0; 9 + padding: 10px; 10 + border-radius: 5px; 11 + margin-bottom: 20px; 12 + } 13 + 14 + .connection-info p { 15 + margin: 5px 0; 16 + } 17 + 18 + .demo-layout { 19 + display: grid; 20 + grid-template-columns: 1fr 1fr; 21 + gap: 20px; 22 + } 23 + 24 + .demo-section { 25 + background: #f9f9f9; 26 + padding: 15px; 27 + border-radius: 5px; 28 + border: 1px solid #ddd; 29 + } 30 + 31 + .demo-section h3 { 32 + margin-top: 0; 33 + } 34 + 35 + /* Peer List Styles */ 36 + .peer-list ul { 37 + list-style: none; 38 + padding: 0; 39 + } 40 + 41 + .peer-item { 42 + padding: 5px; 43 + margin: 5px 0; 44 + background: white; 45 + border-radius: 3px; 46 + display: flex; 47 + align-items: center; 48 + gap: 10px; 49 + } 50 + 51 + .peer-id { 52 + font-family: monospace; 53 + font-size: 12px; 54 + } 55 + 56 + .connection-status { 57 + display: flex; 58 + align-items: center; 59 + gap: 5px; 60 + font-size: 12px; 61 + } 62 + 63 + .status-icon { 64 + font-size: 10px; 65 + } 66 + 67 + .initiator { 68 + color: #666; 69 + font-size: 11px; 70 + } 71 + 72 + /* Message Interface Styles */ 73 + .broadcast-mode { 74 + margin-bottom: 10px; 75 + } 76 + 77 + .broadcast-mode label { 78 + margin-right: 15px; 79 + } 80 + 81 + .message-list { 82 + height: 200px; 83 + overflow-y: auto; 84 + background: white; 85 + padding: 10px; 86 + border: 1px solid #ddd; 87 + border-radius: 3px; 88 + margin-bottom: 10px; 89 + } 90 + 91 + .message { 92 + margin: 5px 0; 93 + } 94 + 95 + .message .sender { 96 + font-weight: bold; 97 + margin-right: 5px; 98 + } 99 + 100 + .message .via { 101 + color: #666; 102 + font-size: 12px; 103 + margin-left: 5px; 104 + } 105 + 106 + .message-input { 107 + display: flex; 108 + gap: 10px; 109 + } 110 + 111 + .message-input input { 112 + flex: 1; 113 + padding: 5px; 114 + } 115 + 116 + /* System Messages */ 117 + .system-messages { 118 + height: 200px; 119 + overflow-y: auto; 120 + background: white; 121 + padding: 10px; 122 + border: 1px solid #ddd; 123 + border-radius: 3px; 124 + font-size: 12px; 125 + } 126 + 127 + .system-message { 128 + margin: 3px 0; 129 + } 130 + 131 + .system-message.error { 132 + color: #d00; 133 + } 134 + 135 + .system-message.info { 136 + color: #333; 137 + } 138 + 139 + .system-message .timestamp { 140 + color: #666; 141 + margin-right: 5px; 142 + } 143 + 144 + .system-message .from { 145 + font-weight: bold; 146 + margin: 0 5px; 147 + }
+126
src/client/webrtc-demo.jsx
··· 1 + // @ts-nocheck 2 + 3 + import { useEffect, useContext, useCallback } from 'preact/hooks' 4 + 5 + import { RealmConnectionContext } from '#client/realm/context.jsx' 6 + import { PeerList } from '#client/components/peer-list.jsx' 7 + 8 + import * as protocol from '#common/protocol.js' 9 + import { generateSigningJwkPair } from '#common/crypto/jwks.js' 10 + import { Messenger } from './components/messenger' 11 + 12 + /** 13 + * @private 14 + * @param {EventTarget} target event target to attach 15 + * @param {string} name the name of the event to listen to 16 + * @param {function(): void} listener the listener to add 17 + * @returns {function(): void} the listener, for removing later 18 + */ 19 + function attachEventListener(target, name, listener) { 20 + target.addEventListener(name, listener) 21 + return listener 22 + } 23 + 24 + /** 25 + * @private 26 + * @returns {preact.JSX.Element} 27 + */ 28 + export function WebRTCDemo() { 29 + const context = useContext(RealmConnectionContext) 30 + if (!context) 31 + throw new Error('expected to be called inside realm connection context!') 32 + 33 + useEffect(() => { 34 + if (!context.realm) return 35 + 36 + /* setup event listeners on the socket */ 37 + 38 + const wsopen = attachEventListener(context.realm, 'wsopen', () => { 39 + console.log('connection socket open!') 40 + }) 41 + 42 + const wsdata = attachEventListener(context.realm, 'wsdata', (e) => { 43 + console.log('connection socket data!', e) 44 + }) 45 + 46 + const wserror = attachEventListener(context.realm, 'wserror', (e) => { 47 + console.log('connection socket error!', e) 48 + }) 49 + 50 + const wsclose = attachEventListener(context.realm, 'wsclose', (e) => { 51 + console.log('connection socket closed!', e) 52 + }) 53 + 54 + const peeropen = attachEventListener(context.realm, 'peeropen', (p) => { 55 + console.log('peer connected', p) 56 + }) 57 + 58 + const peerdata = attachEventListener(context.realm, 'peerdata', (p) => { 59 + console.log('peer data', p) 60 + }) 61 + 62 + const peerclose = attachEventListener(context.realm, 'peerclose', (p) => { 63 + console.log('peer disconnected', p) 64 + }) 65 + 66 + const peererror = attachEventListener(context.realm, 'peererror', (p) => { 67 + console.log('peer error', p) 68 + }) 69 + 70 + return () => { 71 + context.realm?.removeEventListener('wsopen', wsopen) 72 + context.realm?.removeEventListener('wsdata', wsdata) 73 + context.realm?.removeEventListener('wserror', wserror) 74 + context.realm?.removeEventListener('wsclose', wsclose) 75 + 76 + context.realm?.removeEventListener('peeropen', peeropen) 77 + context.realm?.removeEventListener('peerdata', peerdata) 78 + context.realm?.removeEventListener('peererror', peererror) 79 + context.realm?.removeEventListener('peerclose', peerclose) 80 + } 81 + }) 82 + 83 + const connect = useCallback(async () => { 84 + const realmid = protocol.RealmBrand.parse('realm-n7-qM0rOzsJ8N-iF') // hard code for now 85 + const identid = protocol.IdentBrand.generate() 86 + const keypair = await generateSigningJwkPair() 87 + 88 + context.setIdentity({ realmid, identid, keypair }) 89 + }, [context]) 90 + 91 + return ( 92 + <div className="webrtc-demo"> 93 + <h1>WebRTC Demo</h1> 94 + 95 + <button onClick={connect}> 96 + Connect 97 + </button> 98 + 99 + <div> 100 + Identity: 101 + <pre>{ JSON.stringify(context.identity, null, 2) }</pre> 102 + </div> 103 + 104 + <div className="connection-info"> 105 + <p> 106 + Status: 107 + {context.realm?.connected ? '🟢 Connected' : '🔴 Disconnected'} 108 + </p> 109 + <pre> 110 + <code> 111 + { JSON.stringify(context.realm, null, 2) } 112 + </code> 113 + </pre> 114 + </div> 115 + 116 + { context.realm && ( 117 + <div className="demo-layout"> 118 + <div className="demo-section"> 119 + <PeerList webrtcManager={context.realm} /> 120 + <Messenger webrtcManager={context.realm} /> 121 + </div> 122 + </div> 123 + )} 124 + </div> 125 + ) 126 + }
+5 -3
src/common/crypto/jwks.js
··· 4 4 import { z } from 'zod/v4' 5 5 import { CryptoError } from './errors.js' 6 6 7 + /** @typedef {jose.JWK} JWK */ 8 + 7 9 const subtleSignAlgo = { name: 'ECDSA', namedCurve: 'P-256' } 8 10 const joseSignAlgo = { name: 'ES256' } 9 11 ··· 25 27 * 26 28 * @see https://www.rfc-editor.org/rfc/rfc7517 27 29 * @see https://github.com/panva/jose/blob/main/src/types.d.ts#L2 28 - * @type {z.ZodType<jose.JWK>} 30 + * @type {z.ZodType<JWK>} 29 31 */ 30 32 export const jwkSchema = z.union([ 31 33 jwkEcPublicSchema, ··· 35 37 /** 36 38 * zod transform from JWK to CryptoKey 37 39 * 38 - * @type {z.ZodTransform<CryptoKey, jose.JWK>} 40 + * @type {z.ZodTransform<CryptoKey, JWK>} 39 41 */ 40 42 export const jwkImport = z.transform(async (val, ctx) => { 41 43 try { ··· 73 75 /** 74 76 * zod transform from exportable CryptoKey to JWK 75 77 * 76 - * @type {z.ZodTransform<jose.JWK, CryptoKey>} 78 + * @type {z.ZodTransform<JWK, CryptoKey>} 77 79 */ 78 80 export const jwkExport = z.transform(async (val, ctx) => { 79 81 try {
+35 -5
src/common/crypto/jwts.js
··· 10 10 /** 11 11 * @typedef {object} JWTToken 12 12 * @property {string} token the still-encoded JWT, for later verification 13 - * @property {jose.JWTPayload} payload the decoded JWT payload, for later verification 14 - * 15 - * A JWTToken is both the decoded payload and the token itself, for later processing. 13 + * @property {jose.JWTPayload} claims the decoded JWT payload, for later verification 14 + */ 15 + 16 + /** 17 + * @template T 18 + * @typedef {object} JWTTokenPayload 19 + * @property {string} token the still-encoded JWT, for later verification 20 + * @property {jose.JWTPayload} claims the decoded JWT payload, for later verification 21 + * @property {T} payload the validate payload type, extracted from a payloadkey 16 22 */ 17 23 18 24 /** ··· 23 29 */ 24 30 export const jwtSchema = z.jwt({ abort: true }).transform((token, ctx) => { 25 31 try { 26 - const payload = jose.decodeJwt(token) 27 - return { payload, token } 32 + const claims = jose.decodeJwt(token) 33 + return { claims, token } 28 34 } 29 35 catch (e) { 30 36 ctx.issues.push({ ··· 36 42 return z.NEVER 37 43 } 38 44 }) 45 + 46 + /** 47 + * schema describing a verified payload in a JWT. 48 + * **important** - this does no claims validation, only decoding from string to JWT! 49 + * 50 + * @template T 51 + * @param {z.ZodType<T>} schema the schema to extract from the payload 52 + * @returns {z.ZodType<JWTTokenPayload<T>>} transformer 53 + */ 54 + export const jwtPayload = (schema) => { 55 + const parser = z.looseObject({ payload: schema }) 56 + 57 + return jwtSchema.transform(async (payload, ctx) => { 58 + const result = await parser.safeParseAsync(payload.claims) 59 + if (result.success) 60 + return { ...payload, payload: result.data.payload } 61 + 62 + result.error.issues.forEach((iss) => { 63 + ctx.issues.push(iss) 64 + }) 65 + 66 + return z.NEVER 67 + }) 68 + } 39 69 40 70 /** @typedef {Partial<Omit<jose.JWTVerifyOptions, 'algorithms'>>} VerifyOpts */ 41 71
+5 -1
src/common/protocol.js
··· 17 17 return JSON.parse(input) 18 18 } 19 19 catch { 20 - ctx.issues.push({ code: 'custom', input, message: 'input could not be parsed as JSON' }) 20 + ctx.issues.push({ 21 + code: 'custom', 22 + input, 23 + message: 'input could not be parsed as JSON', 24 + }) 21 25 22 26 return z.NEVER 23 27 }
+49 -25
src/common/protocol/messages-realm.js
··· 1 1 import { z } from 'zod/v4' 2 - import { IdentBrand, RealmBrand } from './brands.js' 2 + import { IdentBrand } from './brands.js' 3 3 import { okResponseSchema } from './messages.js' 4 4 5 - /** zod schema for `realm.status` message */ 6 - export const realmStatusMessageSchema = z.object({ 7 - msg: z.literal('realm.status'), 8 - }) 9 - /** @typedef {z.infer<typeof realmStatusMessageSchema>} RealmStatusMessage */ 10 - 11 - /** zod schema for `realm.status` response */ 12 - export const realmStatusResponseSchema = okResponseSchema.extend({ 13 - msg: z.literal('realm.status'), 14 - realm: RealmBrand.schema, 15 - identities: z.array(IdentBrand.schema), 16 - }) 17 - /** @typedef {z.infer<typeof realmStatusResponseSchema>} RealmStatusResponse */ 18 - 19 - /** zod schema for `realm.broadcast` message */ 5 + /** 6 + * zod schema for `realm.broadcast` message 7 + * recipients = true, include self in broadcast 8 + * recipients = false, exclude self in broadcast (default) 9 + * recipients = [], use these exact recipients 10 + */ 20 11 export const realmBroadcastMessageSchema = z.object({ 21 12 msg: z.literal('realm.broadcast'), 22 13 payload: z.any(), 23 - recipients: z.array(IdentBrand.schema).optional(), 14 + recipients: z.union([ 15 + z.boolean(), 16 + z.array(IdentBrand.schema), 17 + ]).default(false), 24 18 }) 25 19 /** @typedef {z.infer<typeof realmBroadcastMessageSchema>} RealmBroadcastMessage */ 26 20 27 - /** zod schema for `realm.broadcast` message */ 28 - export const realmBroadcastResponseSchema = okResponseSchema.extend({ 29 - msg: z.literal('realm.broadcast'), 21 + // rtc messages 22 + 23 + export const realmRtcSignalMessageSchema = z.object({ 24 + msg: z.literal('realm.rtc.signal'), 25 + payload: z.string(), 30 26 sender: IdentBrand.schema, 31 - payload: z.any(), 27 + recipient: IdentBrand.schema, 28 + initiator: z.boolean(), 32 29 }) 33 - /** @typedef {z.infer<typeof realmBroadcastResponseSchema>} RealmBroadcastResponse */ 30 + /** @typedef {z.infer<typeof realmRtcSignalMessageSchema>} RealmRtcSignalMessage */ 31 + 32 + export const realmRtcPeerWelcomeMessageSchema = okResponseSchema.extend({ 33 + msg: z.literal('realm.rtc.peer-welcome'), 34 + peers: z.array(IdentBrand.schema), 35 + }) 36 + /** @typedef {z.infer<typeof realmRtcPeerWelcomeMessageSchema>} RealmRtcPeerWelcomeMessage */ 37 + 38 + export const realmRtcPeerJoinedMessageSchema = okResponseSchema.extend({ 39 + msg: z.literal('realm.rtc.peer-joined'), 40 + identid: IdentBrand.schema, 41 + }) 42 + /** @typedef {z.infer<typeof realmRtcPeerJoinedMessageSchema>} RealmRtcPeerJoinedMessage */ 43 + 44 + export const realmRtcPeerLeftMessageSchema = okResponseSchema.extend({ 45 + msg: z.literal('realm.rtc.peer-left'), 46 + identid: IdentBrand.schema, 47 + }) 48 + /** @typedef {z.infer<typeof realmRtcPeerLeftMessageSchema>} RealmRtcPeerLeftMessage */ 49 + 50 + /// useful unions 34 51 35 - /** zod schema for any `realm` messages */ 36 - export const realmMessageSchema = z.discriminatedUnion('msg', [ 37 - realmStatusMessageSchema, 52 + export const realmToServerMessageSchema = z.discriminatedUnion('msg', [ 38 53 realmBroadcastMessageSchema, 54 + realmRtcSignalMessageSchema, 39 55 ]) 56 + /** @typedef {z.infer<typeof realmToServerMessageSchema>} RealmToServerMessage */ 57 + 58 + export const realmFromServerMessageSchema = z.discriminatedUnion('msg', [ 59 + realmRtcPeerJoinedMessageSchema, 60 + realmRtcPeerLeftMessageSchema, 61 + realmRtcSignalMessageSchema, 62 + ]) 63 + /** @typedef {z.infer<typeof realmFromServerMessageSchema>} RealmFromServerMessage */
+52 -2
src/common/socket.js
··· 1 1 /** @module common/socket */ 2 2 3 + import * as z_types from 'zod/v4' 4 + 3 5 import { combineSignals } from '#common/async/aborts.js' 4 6 import { BlockingAtom } from '#common/async/blocking-atom.js' 5 7 import { BlockingQueue } from '#common/async/blocking-queue.js' 6 8 import { Breaker } from '#common/breaker.js' 7 9 import { ProtocolError } from '#common/errors.js' 8 10 9 - import * as protocol_types from './protocol.js' 11 + import { parseJson } from './protocol.js' 10 12 11 13 /** 12 14 * Send some data in JSON format down the wire. 13 15 * 14 16 * @param {WebSocket} ws the socket to send on 15 - * @param {protocol_types.OkResponse | protocol_types.ErrorResponse} data the data to send 17 + * @param {unknown} data the data to send 16 18 */ 17 19 export function sendSocket(ws, data) { 18 20 ws.send(JSON.stringify(data)) ··· 68 70 ws.removeEventListener('error', onError) 69 71 ws.removeEventListener('close', onClose) 70 72 } 73 + } 74 + 75 + /** 76 + * exactly take socket, but will additionally apply a json decoding 77 + * 78 + * @template T the schema's type 79 + * @param {WebSocket} ws the socket to read 80 + * @param {z_types.ZodSchema<T>} schema an a schema to execute 81 + * @param {AbortSignal} [signal] an abort signal to cancel the block 82 + * @returns {Promise<T>} the message off the socket 83 + */ 84 + export async function takeSocketJson(ws, schema, signal) { 85 + const data = await takeSocket(ws, signal) 86 + return parseJson.pipe(schema).parseAsync(data) 71 87 } 72 88 73 89 /** ··· 189 205 ws.removeEventListener('close', onClose) 190 206 } 191 207 } 208 + 209 + /** 210 + * exactly stream socket, but will additionally apply a json decoding 211 + * messages not validating will end the stream with an error 212 + * 213 + * @param {WebSocket} ws the socket to read 214 + * @param {Partial<ConfigProps>} [config] stream configuration to merge into defaults 215 + * @returns {AsyncGenerator<unknown>} an async generator 216 + * @yields the message off the socket 217 + */ 218 + export async function* streamSocketJson(ws, config) { 219 + for await (const message of streamSocket(ws, config)) { 220 + yield parseJson.parseAsync(message) 221 + } 222 + } 223 + 224 + /** 225 + * exactly stream socket, but will additionally apply a json decoding 226 + * messages not validating will end the stream with an error 227 + * 228 + * @template T the schema's type 229 + * @param {WebSocket} ws the socket to read 230 + * @param {z_types.ZodSchema<T>} schema an a schema to execute 231 + * @param {Partial<ConfigProps>} [config] stream configuration to merge into defaults 232 + * @returns {AsyncGenerator<T>} an async generator 233 + * @yields the message off the socket 234 + */ 235 + export async function* streamSocketSchema(ws, schema, config) { 236 + const parser = parseJson.pipe(schema) 237 + 238 + for await (const message of streamSocket(ws, config)) { 239 + yield await parser.parseAsync(message) 240 + } 241 + }
+12 -3
src/common/strict-map.js
··· 35 35 36 36 /** 37 37 * @param {K} key to update in the map 38 - * @param {function(V=): V} update function which returns the new value for the map 38 + * @param {function(V=): V | undefined} update 39 + * function which returns the new value for the map 40 + * if the return value is REMOVE_KEY, the whole entry in the map will be removed. 39 41 */ 40 42 update(key, update) { 41 - const current = this.get(key) 42 - this.set(key, update(current)) 43 + const prev = this.get(key) 44 + const next = update(prev) 45 + 46 + if (next === undefined) { 47 + this.delete(key) 48 + } 49 + else { 50 + this.set(key, next) 51 + } 43 52 } 44 53 45 54 }
+6 -7
src/server/routes-socket/handler-preauth.js
··· 1 1 import { combineSignals, timeoutSignal } from '#common/async/aborts.js' 2 2 import { jwkImport } from '#common/crypto/jwks.js' 3 - import { jwtSchema, verifyJwtToken } from '#common/crypto/jwts.js' 3 + import { jwtPayload, verifyJwtToken } from '#common/crypto/jwts.js' 4 4 import { normalizeError, ProtocolError } from '#common/errors.js' 5 5 import { IdentBrand, preauthMessageSchema, RealmBrand } from '#common/protocol.js' 6 6 import { takeSocket } from '#common/socket.js' ··· 25 25 const data = await takeSocket(ws, combinedSignal) 26 26 27 27 // if any of the parsing fails, it'll throw a zod error 28 - const jwt = jwtSchema.parse(data) 29 - const msg = await preauthMessageSchema.parseAsync(jwt.payload) 30 - const identid = IdentBrand.parse(jwt.payload.iss) 31 - const realmid = RealmBrand.parse(jwt.payload.aud) 28 + const jwt = await jwtPayload(preauthMessageSchema).parseAsync(data) 29 + const identid = IdentBrand.parse(jwt.claims.iss) 30 + const realmid = RealmBrand.parse(jwt.claims.aud) 32 31 33 32 // if we're registering, make sure the realm exists 34 - if (msg.msg === 'preauth.register') { 35 - const registrantkey = await jwkImport.parseAsync(msg.pubkey) 33 + if (jwt.payload.msg === 'preauth.register') { 34 + const registrantkey = await jwkImport.parseAsync(jwt.payload.pubkey) 36 35 realms.ensureRegisteredRealm(realmid, identid, registrantkey) 37 36 } 38 37
+82 -51
src/server/routes-socket/handler-realm.js
··· 1 1 import { normalizeProtocolError, ProtocolError } from '#common/errors.js' 2 - import { realmMessageSchema, parseJson } from '#common/protocol.js' 2 + import { realmToServerMessageSchema, parseJson } from '#common/protocol.js' 3 3 import { sendSocket, streamSocket } from '#common/socket.js' 4 - import { format } from 'node:util' 5 4 6 5 import * as protocol_types from '#common/protocol.js' 7 6 import * as realm_types from '#server/routes-socket/state.js' ··· 15 14 * @param {AbortSignal} [signal] an optional signal to abort the blocking loop 16 15 */ 17 16 export async function realmHandler(ws, auth, signal) { 18 - respondWithRealmStatus(ws, auth) 19 - broadcastToRealm(undefined, { ok: true, msg: 'welcome', ident: auth.identid }, auth) 17 + realmBroadcast(auth, buildRtcPeerJoined(auth)) 18 + sendSocket(ws, buildRtcPeerWelcome(auth)) 20 19 21 - for await (const data of streamSocket(ws, { signal })) { 22 - try { 23 - const msg = await parseJson.pipe(realmMessageSchema).parseAsync(data) 24 - switch (msg.msg) { 25 - case 'realm.status': 26 - respondWithRealmStatus(ws, auth) 27 - continue 20 + try { 21 + for await (const data of streamSocket(ws, { signal })) { 22 + try { 23 + const msg = await parseJson.pipe(realmToServerMessageSchema).parseAsync(data) 24 + switch (msg.msg) { 25 + case 'realm.broadcast': 26 + realmBroadcast(auth, msg.payload, msg.recipients) 27 + continue 28 28 29 - case 'realm.broadcast': 30 - broadcastToRealm(msg.recipients, msg.payload, auth) 31 - continue 29 + case 'realm.rtc.signal': 30 + realmBroadcast(auth, msg, [msg.recipient]) 31 + continue 32 32 33 - default: 34 - throw new ProtocolError('unknown message type: ${msg.msg}', 400) 33 + default: 34 + throw new ProtocolError(`unknown message type: ${msg}`, 400) 35 + } 35 36 } 36 - } 37 - catch (exc) { 38 - const error = normalizeProtocolError(exc) 39 - if (error.status >= 500) throw error 37 + catch (exc) { 38 + const error = normalizeProtocolError(exc) 39 + if (error.status >= 500) 40 + throw error 40 41 41 - if (ws.readyState === ws.OPEN) { 42 - /** @type {protocol_types.ErrorResponse} */ 43 - const resp = { 44 - ok: false, 45 - message: format('Error: %s', error.message), 46 - status: error.status, 42 + if (ws.readyState === ws.OPEN) { 43 + sendSocket(ws, buildRealmError(error)) 47 44 } 48 - 49 - sendSocket(ws, resp) 50 45 } 51 46 } 52 47 } 48 + finally { 49 + console.log('client left!', auth) 50 + realmBroadcast(auth, buildRtcPeerLeft(auth)) 51 + } 53 52 } 54 53 55 54 /** 56 55 * @private 57 - * @param {WebSocket} ws the socket to communicate on 58 56 * @param {realm_types.AuthenticatedConnection} auth the current identity 57 + * @returns {protocol_types.RealmRtcPeerWelcomeMessage} a realm welcome response 59 58 */ 60 - function respondWithRealmStatus(ws, auth) { 61 - /** @type {protocol_types.RealmStatusResponse} */ 62 - const resp = { 59 + function buildRtcPeerWelcome(auth) { 60 + return { 63 61 ok: true, 64 - msg: 'realm.status', 65 - realm: auth.realmid, 66 - identities: Array.from(auth.realm.identities.keys()), 62 + msg: 'realm.rtc.peer-welcome', 63 + peers: Array.from(auth.realm.sockets.keys()), 67 64 } 65 + } 68 66 69 - sendSocket(ws, resp) 67 + /** 68 + * @private 69 + * @param {realm_types.AuthenticatedConnection} auth the current identity 70 + * @returns {protocol_types.RealmRtcPeerJoinedMessage} a realm status response 71 + */ 72 + function buildRtcPeerJoined(auth) { 73 + return { 74 + ok: true, 75 + msg: 'realm.rtc.peer-joined', 76 + identid: auth.identid, 77 + } 70 78 } 71 79 72 80 /** 73 81 * @private 74 - * @param {protocol_types.IdentID[] | undefined} recipients array of recips, or undef to send all 75 - * @param {unknown} payload the payload to send 76 82 * @param {realm_types.AuthenticatedConnection} auth the current identity 83 + * @returns {protocol_types.RealmRtcPeerLeftMessage} a realm status response 77 84 */ 78 - function broadcastToRealm(recipients, payload, auth) { 79 - /** @type {protocol_types.RealmBroadcastResponse} */ 80 - const resp = { 85 + function buildRtcPeerLeft(auth) { 86 + return { 81 87 ok: true, 82 - msg: 'realm.broadcast', 83 - sender: auth.identid, 84 - payload: payload, 88 + msg: 'realm.rtc.peer-left', 89 + identid: auth.identid, 85 90 } 91 + } 86 92 87 - recipients ??= Array.from(auth.realm.identities.keys()) 88 - for (const recip of recipients) { 89 - if (recip === auth.identid) continue 93 + /** 94 + * @private 95 + * @param {ProtocolError} error the error to report 96 + * @returns {protocol_types.ErrorResponse} a realm error response 97 + */ 98 + function buildRealmError(error) { 99 + return { 100 + ok: false, 101 + message: error.message, 102 + status: error.status, 103 + } 104 + } 90 105 91 - const sockets = auth.realm.sockets.get(recip) 92 - if (sockets == null) continue 106 + /** 107 + * @private 108 + * @param {realm_types.AuthenticatedConnection} auth the current identity 109 + * @param {unknown} payload the payload to send 110 + * @param {protocol_types.IdentID[] | boolean} [recipients] 111 + * when true, send to the whole realm, including self 112 + * when false, send to the whole realm, excluding self 113 + * when an array of recipients, send to those recipients explicitly 114 + */ 115 + function realmBroadcast(auth, payload, recipients) { 116 + const echo = recipients === true || Array.isArray(recipients) 117 + const recips = Array.isArray(recipients) ? recipients : Array.from(auth.realm.identities.keys()) 93 118 94 - for (const socket of sockets) { 95 - sendSocket(socket, resp) 119 + for (const recip of recips) { 120 + if (!echo && recip === auth.identid) continue 121 + 122 + const sockets = auth.realm.sockets.get(recip) 123 + if (sockets) { 124 + for (const socket of sockets) { 125 + sendSocket(socket, payload) 126 + } 96 127 } 97 128 } 98 129 }
+9 -2
src/server/routes-socket/state.js
··· 34 34 * @returns {Realm} a registered realm, possibly newly created with the registrant 35 35 */ 36 36 export function ensureRegisteredRealm(realmid, registrantid, registrantkey) { 37 - return realmMap.ensure(realmid, () => ({ 37 + const realm = realmMap.ensure(realmid, () => ({ 38 38 realmid, 39 39 sockets: new StrictMap(), 40 40 identities: new StrictMap([[registrantid, registrantkey]]), 41 41 })) 42 + 43 + // hack for now, allow any registration to work 44 + realm.identities.ensure(registrantid, () => registrantkey) 45 + return realm 42 46 } 43 47 44 48 /** ··· 56 60 * @param {WebSocket} socket the socket to dettach 57 61 */ 58 62 export function detachSocket(realm, ident, socket) { 59 - realm.sockets.update(ident, ss => ss ? ss.filter(s => s !== socket) : []) 63 + realm.sockets.update(ident, (sockets) => { 64 + const next = sockets?.filter(s => s !== socket) 65 + return next?.length ? next : undefined 66 + }) 60 67 }