+51
src/client/components/messenger.jsx
+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
+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
-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
+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
-1
src/client/page-app.spec.jsx
+425
src/client/realm/connection.js
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
}