tangled
alpha
login
or
join now
accidental.cc
/
skypod
3
fork
atom
podcast manager
3
fork
atom
overview
issues
pulls
pipelines
wip on default realm stuff
Jonathan Raphaelson
8 months ago
55e3e528
f94158fb
+448
-213
18 changed files
expand all
collapse all
unified
split
eslint.config.js
package-lock.json
package.json
src
client
components
peer-list.tsx
page-app.tsx
realm
connection.ts
context-connection.tsx
context-identity.tsx
context.tsx
identity.ts
types.ts
realm-connection-manager.tsx
webrtc-demo.tsx
common
crypto
jwks.ts
jwts.ts
protocol
messages.ts
server
routes-socket
handler-preauth.ts
state.ts
+1
eslint.config.js
···
50
'@typescript-eslint/no-unused-vars': ['warn', {varsIgnorePattern: '(?:^_)'}],
51
'@typescript-eslint/no-unnecessary-condition': 'off',
52
'@typescript-eslint/restrict-template-expressions': 'off',
0
53
'@typescript-eslint/no-unnecessary-type-parameters': 'off',
54
'@typescript-eslint/no-unnecessary-type-constraint': 'off',
55
'tsdoc/syntax': 'warn',
···
50
'@typescript-eslint/no-unused-vars': ['warn', {varsIgnorePattern: '(?:^_)'}],
51
'@typescript-eslint/no-unnecessary-condition': 'off',
52
'@typescript-eslint/restrict-template-expressions': 'off',
53
+
'@typescript-eslint/only-throw-error': 'off',
54
'@typescript-eslint/no-unnecessary-type-parameters': 'off',
55
'@typescript-eslint/no-unnecessary-type-constraint': 'off',
56
'tsdoc/syntax': 'warn',
+27
package-lock.json
···
8
"name": "skypod",
9
"version": "0.0.0",
10
"dependencies": {
0
11
"express": "^5.1.0",
12
"isomorphic-ws": "^5.0.0",
13
"jose": "^6.0.11",
···
2399
"peerDependencies": {
2400
"@babel/core": "7.x",
2401
"vite": "2.x || 3.x || 4.x || 5.x || 6.x"
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
2402
}
2403
},
2404
"node_modules/@prefresh/babel-plugin": {
···
8
"name": "skypod",
9
"version": "0.0.0",
10
"dependencies": {
11
+
"@preact/signals": "^2.2.0",
12
"express": "^5.1.0",
13
"isomorphic-ws": "^5.0.0",
14
"jose": "^6.0.11",
···
2400
"peerDependencies": {
2401
"@babel/core": "7.x",
2402
"vite": "2.x || 3.x || 4.x || 5.x || 6.x"
2403
+
}
2404
+
},
2405
+
"node_modules/@preact/signals": {
2406
+
"version": "2.2.0",
2407
+
"resolved": "https://registry.npmjs.org/@preact/signals/-/signals-2.2.0.tgz",
2408
+
"integrity": "sha512-P3KPcEYyVk9Wiwfw68QQzRpPkt0H+zjfH3X4AaGCDlc86GuRBYFGiAxT1nC5F5qlsVIEmjNJ9yVYe7C91z3L+g==",
2409
+
"license": "MIT",
2410
+
"dependencies": {
2411
+
"@preact/signals-core": "^1.9.0"
2412
+
},
2413
+
"funding": {
2414
+
"type": "opencollective",
2415
+
"url": "https://opencollective.com/preact"
2416
+
},
2417
+
"peerDependencies": {
2418
+
"preact": ">= 10.25.0"
2419
+
}
2420
+
},
2421
+
"node_modules/@preact/signals-core": {
2422
+
"version": "1.10.0",
2423
+
"resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.10.0.tgz",
2424
+
"integrity": "sha512-qlKeXlfqtlC+sjxCPHt6Sk0/dXBrKZVcPlianqjNc/vW263YBFiP5mRrgKpHoO0q222Thm1TdYQWfCKpbbgvwA==",
2425
+
"license": "MIT",
2426
+
"funding": {
2427
+
"type": "opencollective",
2428
+
"url": "https://opencollective.com/preact"
2429
}
2430
},
2431
"node_modules/@prefresh/babel-plugin": {
+1
package.json
···
20
"#server/*": "./src/server/*"
21
},
22
"dependencies": {
0
23
"express": "^5.1.0",
24
"isomorphic-ws": "^5.0.0",
25
"jose": "^6.0.11",
···
20
"#server/*": "./src/server/*"
21
},
22
"dependencies": {
23
+
"@preact/signals": "^2.2.0",
24
"express": "^5.1.0",
25
"isomorphic-ws": "^5.0.0",
26
"jose": "^6.0.11",
+1
-6
src/client/components/peer-list.tsx
···
13
14
const updatePeers = () => {
15
queueMicrotask(() => {
16
-
const states = webrtcManager.getPeerStates()
17
-
console.debug('updating peers', states)
18
-
setPeers(states)
19
})
20
}
21
···
65
return (
66
<div className="connection-status">
67
<span className="status-icon">{getStatusIcon()}</span>
68
-
<code className="initiator">
69
-
{state.address.family}-{state.address.address}:{state.address.port}
70
-
</code>
71
</div>
72
)
73
}
···
13
14
const updatePeers = () => {
15
queueMicrotask(() => {
16
+
setPeers(webrtcManager.peers)
0
0
17
})
18
}
19
···
63
return (
64
<div className="connection-status">
65
<span className="status-icon">{getStatusIcon()}</span>
0
0
0
66
</div>
67
)
68
}
+12
-4
src/client/page-app.tsx
···
1
-
import {RealmConnectionProvider} from './realm/context'
0
0
2
import {WebRTCDemo} from './webrtc-demo'
3
4
export const App: preact.FunctionComponent = () => {
0
0
0
5
return (
6
-
<RealmConnectionProvider url="ws://localhost:3001/stream">
7
-
<WebRTCDemo />
8
-
</RealmConnectionProvider>
0
0
0
9
)
10
}
···
1
+
import {RealmConnectionManager} from './realm-connection-manager'
2
+
import {RealmConnectionProvider} from './realm/context-connection'
3
+
import {RealmIdentityFallbackProps, RealmIdentityProvider} from './realm/context-identity'
4
import {WebRTCDemo} from './webrtc-demo'
5
6
export const App: preact.FunctionComponent = () => {
7
+
const identityFallback = (p: RealmIdentityFallbackProps) =>
8
+
p.loading ? <h1>Loading</h1> : <h1>Error! {p.error?.message}</h1>
9
+
10
return (
11
+
<RealmIdentityProvider fallback={identityFallback}>
12
+
<RealmConnectionProvider url="ws://localhost:3001/stream">
13
+
<RealmConnectionManager />
14
+
<WebRTCDemo />
15
+
</RealmConnectionProvider>
16
+
</RealmIdentityProvider>
17
)
18
}
+54
src/client/realm-connection-manager.tsx
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import {RealmBrand} from '#common/protocol'
2
+
import {useSignal} from '@preact/signals'
3
+
import {useCallback, useContext} from 'preact/hooks'
4
+
import {RealmConnectionContext} from './realm/context-connection'
5
+
import {RealmIdentityContext} from './realm/context-identity'
6
+
7
+
export const RealmConnectionManager: preact.FunctionComponent = () => {
8
+
const identityContext = useContext(RealmIdentityContext)
9
+
if (!identityContext) throw new Error('expected to be called inside realm identity context!')
10
+
11
+
const connectionContext = useContext(RealmConnectionContext)
12
+
if (!connectionContext) throw new Error('expected to be called inside realm connection context!')
13
+
14
+
const invitation = useSignal<string>()
15
+
16
+
const register = useCallback(() => {
17
+
console.log('register')
18
+
connectionContext.realmid.value = RealmBrand.generate()
19
+
}, [connectionContext.realmid])
20
+
21
+
const exchange = useCallback(() => {
22
+
console.log('exchange', invitation.value)
23
+
}, [invitation])
24
+
25
+
return connectionContext?.realmid.value ? (
26
+
<h1>
27
+
Connection:
28
+
{connectionContext.realmid.value}
29
+
{connectionContext.connected.value ? '🟢 Connected' : '🔴 Disconnected'}
30
+
</h1>
31
+
) : (
32
+
<div>
33
+
<h1>
34
+
Connection: <code>NONE</code>
35
+
</h1>
36
+
<p>
37
+
Generate a new realm:{' '}
38
+
<button type="button" onClick={register}>
39
+
Register
40
+
</button>
41
+
</p>
42
+
<p>
43
+
Exchange an invite:
44
+
<textarea
45
+
value={invitation.value}
46
+
onInput={(e) => (invitation.value = e.currentTarget.value)}
47
+
/>
48
+
<button type="button" onClick={exchange}>
49
+
Exchange
50
+
</button>
51
+
</p>
52
+
</div>
53
+
)
54
+
}
+101
-84
src/client/realm/connection.ts
···
3
import SimplePeer, {SimplePeerData} from 'simple-peer'
4
import {z} from 'zod/v4'
5
6
-
import {generateSignableJwt, jwkExport} from '#common/crypto/jwks'
7
import {normalizeError, normalizeProtocolError, ProtocolError} from '#common/errors'
8
import {
9
IdentID,
10
PreauthRegisterRequest,
11
preauthRespSchema,
0
12
realmRtcPeerJoinedEventSchema,
13
realmRtcPeerLeftEventSchema,
14
realmRtcPingRequestSchema,
···
18
realmRtcSignalEventSchema,
19
} from '#common/protocol'
20
import {streamSocketJson, takeSocketJson} from '#common/socket'
0
21
import {RealmPeer} from './peer'
22
-
import {ConnectionIdentity, PeerState} from './types'
23
24
const realmRtcMessagesSchema = z.union([
25
realmRtcSignalEventSchema,
···
31
/** manages websocket and webrtc connections for a realm */
32
export class RealmConnection extends EventTarget {
33
#url: string
34
-
#identity: ConnectionIdentity
0
35
36
#socket: WebSocket
37
#peers: Map<IdentID, RealmPeer>
38
#nonces: Map<IdentID, string>
39
40
-
constructor(url: string, identity: ConnectionIdentity) {
41
super()
42
43
this.#url = url
0
44
this.#identity = identity
45
46
this.#peers = new Map()
47
this.#nonces = new Map()
48
0
49
this.#socket = new WebSocket(this.#url)
50
this.#socket.onopen = this.#handleSocketOpen
51
this.#socket.onclose = this.#handleSocketClose
···
56
return this.#socket.readyState === this.#socket.OPEN
57
}
58
0
0
0
0
0
0
0
0
0
0
0
0
0
59
send<T extends unknown>(identid: IdentID, data: T) {
60
-
const peer = this.#peers.get(identid)
61
-
if (!peer?.connected) throw new Error(`Not connected to peer: ${identid}`)
62
-
63
-
peer.send(JSON.stringify(data))
64
}
65
66
sendRaw(identid: IdentID, data: SimplePeerData) {
···
71
}
72
73
broadcast(data: unknown, self = false) {
0
0
0
0
74
this.#peers.forEach((peer, identid) => {
75
-
if (self || identid !== this.#identity.identid) peer.send(JSON.stringify(data))
76
})
77
}
78
79
-
getPeerStates(): Record<IdentID, PeerState> {
80
-
const states: Record<IdentID, PeerState> = {}
81
-
for (const [identid, peer] of this.#peers) {
82
-
states[identid] = {
83
-
identid,
84
-
address: peer.address(),
85
-
connected: peer.connected,
86
-
destroyed: peer.destroyed,
87
-
}
0
0
88
}
89
90
-
return states
0
91
}
92
93
-
destroy() {
94
-
console.debug('realm connection destroy!')
0
95
96
-
if (this.connected) this.#socket.close()
97
-
for (const peer of this.#peers.values()) peer.destroy()
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
98
99
-
this.#peers.clear()
100
-
this.#nonces.clear()
0
0
0
101
}
102
103
// do the auth dance
104
// TODO: this should be a state machine
105
-
#connectionAuthenticate = async () => {
106
try {
107
-
const pubkey = await jwkExport.parseAsync(this.#identity.keypair.publicKey)
108
await this.#socketSignedWrite<PreauthRegisterRequest>({
109
typ: 'req',
110
msg: 'preauth.register',
111
-
dat: {pubkey},
0
0
112
})
113
114
const resp = await takeSocketJson(this.#socket, preauthRespSchema)
···
127
}
128
129
// handle some message coming in on the _websocket_
130
-
#connectionMessage = (data: unknown) => {
131
const parse = realmRtcMessagesSchema.safeParse(data)
132
if (!parse.success) {
133
// publish non-handled data to the listeners
···
165
}
166
}
167
0
0
0
0
0
0
0
0
0
0
0
0
168
#connectPeer(remoteid: IdentID, initiator: boolean): RealmPeer {
169
let peer = this.#peers.get(remoteid)
170
if (!peer) {
···
190
}
191
}
192
193
-
// handle socket open is the main loop for the sendSocket
194
-
#handleSocketOpen: WebSocket['onopen'] = async () => {
195
-
if (this.#socket == undefined) throw new Error('socket open handler called with no socket?')
196
-
197
-
console.debug('realm connection, socket loop open')
198
-
this.#dispatchCustomEvent('wsopen')
199
-
200
-
try {
201
-
await this.#connectionAuthenticate()
202
-
for await (const data of streamSocketJson(this.#socket)) {
203
-
this.#connectionMessage(data)
204
-
}
205
-
} catch (exc) {
206
-
const err = normalizeProtocolError(exc)
207
-
208
-
console.error('realm connection, socket loop error', err)
209
-
this.#dispatchCustomEvent('wserror', {error: err})
210
-
} finally {
211
-
this.destroy()
212
-
}
213
-
}
214
-
215
-
#handleSocketError: WebSocket['onerror'] = (exc) => {
216
-
this.#dispatchCustomEvent('wserror', {error: normalizeProtocolError(exc)})
217
-
this.destroy()
218
-
}
219
-
220
-
#handleSocketClose: WebSocket['onclose'] = () => {
221
-
this.#dispatchCustomEvent('wsclose')
222
-
this.destroy()
223
-
}
224
-
225
#handlePeerSignal = (peer: RealmPeer, payload: SimplePeer.SignalData) => {
226
const msg: RealmRtcSignalEvent = {
227
typ: 'evt',
···
257
258
#handlePeerData = (peer: RealmPeer, chunk: string | Uint8Array) => {
259
const data = typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)
260
-
const parsed = realmRtcPingRequestSchema.safeParse(chunk)
261
if (parsed.success) {
262
// reply to pings with pongs
263
this.send<RealmRtcPongResponse>(peer.identid, {
···
268
} else {
269
this.#dispatchCustomEvent('peerdata', {identid: peer.identid, data})
270
}
271
-
}
272
-
273
-
// helpers that are just too damn long
274
-
275
-
#dispatchCustomEvent(type: string, detail?: object) {
276
-
this.dispatchEvent(new CustomEvent(type, {detail}))
277
-
}
278
-
279
-
// typed helpers
280
-
// @example
281
-
// this.#socketWrite<YourType>({
282
-
// ... // <- get type errors here
283
-
// })
284
-
285
-
#socketSignedWrite = async <T extends object>(payload: T) => {
286
-
const token = await generateSignableJwt({
287
-
aud: this.#identity.realmid,
288
-
iss: this.#identity.identid,
289
-
payload,
290
-
}).sign(this.#identity.keypair.privateKey)
291
-
292
-
this.#socket.send(token)
293
}
294
}
···
3
import SimplePeer, {SimplePeerData} from 'simple-peer'
4
import {z} from 'zod/v4'
5
6
+
import {generateSignableJwt} from '#common/crypto/jwks'
7
import {normalizeError, normalizeProtocolError, ProtocolError} from '#common/errors'
8
import {
9
IdentID,
10
PreauthRegisterRequest,
11
preauthRespSchema,
12
+
RealmID,
13
realmRtcPeerJoinedEventSchema,
14
realmRtcPeerLeftEventSchema,
15
realmRtcPingRequestSchema,
···
19
realmRtcSignalEventSchema,
20
} from '#common/protocol'
21
import {streamSocketJson, takeSocketJson} from '#common/socket'
22
+
import {RealmIdentity} from './identity'
23
import {RealmPeer} from './peer'
24
+
import {PeerState} from './types'
25
26
const realmRtcMessagesSchema = z.union([
27
realmRtcSignalEventSchema,
···
33
/** manages websocket and webrtc connections for a realm */
34
export class RealmConnection extends EventTarget {
35
#url: string
36
+
#realmid: RealmID
37
+
#identity: RealmIdentity
38
39
#socket: WebSocket
40
#peers: Map<IdentID, RealmPeer>
41
#nonces: Map<IdentID, string>
42
43
+
constructor(url: string, realmid: RealmID, identity: RealmIdentity) {
44
super()
45
46
this.#url = url
47
+
this.#realmid = realmid
48
this.#identity = identity
49
50
this.#peers = new Map()
51
this.#nonces = new Map()
52
53
+
console.debug('realm connection starting!')
54
this.#socket = new WebSocket(this.#url)
55
this.#socket.onopen = this.#handleSocketOpen
56
this.#socket.onclose = this.#handleSocketClose
···
61
return this.#socket.readyState === this.#socket.OPEN
62
}
63
64
+
get peers(): Record<IdentID, PeerState> {
65
+
const states: Record<IdentID, PeerState> = {}
66
+
for (const [identid, peer] of this.#peers) {
67
+
states[identid] = {
68
+
identid,
69
+
connected: peer.connected,
70
+
destroyed: peer.destroyed,
71
+
}
72
+
}
73
+
74
+
return states
75
+
}
76
+
77
send<T extends unknown>(identid: IdentID, data: T) {
78
+
this.sendRaw(identid, JSON.stringify(data))
0
0
0
79
}
80
81
sendRaw(identid: IdentID, data: SimplePeerData) {
···
86
}
87
88
broadcast(data: unknown, self = false) {
89
+
this.broadcastRaw(JSON.stringify(data), self)
90
+
}
91
+
92
+
broadcastRaw(data: SimplePeerData, self = false) {
93
this.#peers.forEach((peer, identid) => {
94
+
if (self || identid !== this.#identity.identid) peer.send(data)
95
})
96
}
97
98
+
destroy() {
99
+
console.debug('realm connection destroy!')
100
+
101
+
// disconnect from socket
102
+
if (this.connected) {
103
+
this.#socket.close()
104
+
}
105
+
106
+
// disconnect from peers
107
+
for (const peer of this.#peers.values()) {
108
+
peer.destroy()
109
}
110
111
+
this.#peers.clear()
112
+
this.#nonces.clear()
113
}
114
115
+
#dispatchCustomEvent(type: string, detail?: object) {
116
+
this.dispatchEvent(new CustomEvent(type, {detail}))
117
+
}
118
119
+
// typed helpers
120
+
// @example
121
+
// this.#socketWrite<YourType>({
122
+
// ... // <- get type errors here
123
+
// })
124
+
125
+
async #socketSignedWrite<T extends object>(payload: T) {
126
+
const token = await this.#identity.sign(
127
+
generateSignableJwt({
128
+
aud: this.#realmid,
129
+
iss: this.#identity.identid,
130
+
payload,
131
+
}),
132
+
)
133
+
134
+
this.#socket.send(token)
135
+
}
136
+
137
+
/// event handlers
138
+
139
+
// handle socket open is the main loop for the sendSocket
140
+
#handleSocketOpen: WebSocket['onopen'] = async () => {
141
+
if (this.#socket == undefined) throw new Error('socket open handler called with no socket?')
142
+
143
+
console.debug('realm connection, socket loop open')
144
+
this.#dispatchCustomEvent('wsopen')
145
+
146
+
try {
147
+
await this.#handleOpenAuthenticate()
148
+
for await (const data of streamSocketJson(this.#socket)) {
149
+
this.#handleOpenMessage(data)
150
+
}
151
+
} catch (exc) {
152
+
const err = normalizeProtocolError(exc)
153
154
+
console.error('realm connection, socket loop error', err)
155
+
this.#dispatchCustomEvent('wserror', {error: err})
156
+
} finally {
157
+
this.destroy()
158
+
}
159
}
160
161
// do the auth dance
162
// TODO: this should be a state machine
163
+
#handleOpenAuthenticate = async () => {
164
try {
0
165
await this.#socketSignedWrite<PreauthRegisterRequest>({
166
typ: 'req',
167
msg: 'preauth.register',
168
+
dat: {
169
+
pubkey: await this.#identity.pubjwk,
170
+
},
171
})
172
173
const resp = await takeSocketJson(this.#socket, preauthRespSchema)
···
186
}
187
188
// handle some message coming in on the _websocket_
189
+
#handleOpenMessage = (data: unknown) => {
190
const parse = realmRtcMessagesSchema.safeParse(data)
191
if (!parse.success) {
192
// publish non-handled data to the listeners
···
224
}
225
}
226
227
+
#handleSocketError: WebSocket['onerror'] = (exc) => {
228
+
this.#dispatchCustomEvent('wserror', {error: normalizeProtocolError(exc)})
229
+
this.destroy()
230
+
}
231
+
232
+
#handleSocketClose: WebSocket['onclose'] = () => {
233
+
this.#dispatchCustomEvent('wsclose')
234
+
this.destroy()
235
+
}
236
+
237
+
// peers
238
+
239
#connectPeer(remoteid: IdentID, initiator: boolean): RealmPeer {
240
let peer = this.#peers.get(remoteid)
241
if (!peer) {
···
261
}
262
}
263
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
264
#handlePeerSignal = (peer: RealmPeer, payload: SimplePeer.SignalData) => {
265
const msg: RealmRtcSignalEvent = {
266
typ: 'evt',
···
296
297
#handlePeerData = (peer: RealmPeer, chunk: string | Uint8Array) => {
298
const data = typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)
299
+
const parsed = realmRtcPingRequestSchema.safeParse(data)
300
if (parsed.success) {
301
// reply to pings with pongs
302
this.send<RealmRtcPongResponse>(peer.identid, {
···
307
} else {
308
this.#dispatchCustomEvent('peerdata', {identid: peer.identid, data})
309
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
310
}
311
}
+61
src/client/realm/context-connection.tsx
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import {Signal, useSignal, useSignalEffect} from '@preact/signals'
2
+
import {createContext} from 'preact'
3
+
import {useCallback, useContext} from 'preact/hooks'
4
+
5
+
import {RealmConnection} from '#client/realm/connection'
6
+
import {RealmID} from '#common/protocol.js'
7
+
import {RealmIdentityContext} from './context-identity'
8
+
9
+
export interface RealmConnectionValues {
10
+
realm: Signal<RealmConnection | undefined>
11
+
realmid: Signal<RealmID | undefined>
12
+
connected: Signal<boolean>
13
+
}
14
+
15
+
export const RealmConnectionContext = createContext<RealmConnectionValues | null>(null)
16
+
17
+
export const RealmConnectionProvider: preact.FunctionComponent<{
18
+
url: string
19
+
realmid?: RealmID
20
+
children: preact.ComponentChildren
21
+
}> = (props) => {
22
+
const context = useContext(RealmIdentityContext)
23
+
if (!context) throw new Error('expected to be called inside realm identity context!')
24
+
25
+
const connected$ = useSignal<boolean>(false)
26
+
const connection$ = useSignal<RealmConnection>()
27
+
const realmid$ = useSignal(props.realmid)
28
+
29
+
const onChange = useCallback(() => {
30
+
console.log('on change called!', connection$.value?.connected)
31
+
connected$.value = connection$.value?.connected || false
32
+
}, [connected$, connection$])
33
+
34
+
useSignalEffect(() => {
35
+
if (!realmid$.value) return
36
+
37
+
const connection = new RealmConnection(props.url, realmid$.value, context.identity)
38
+
connection.addEventListener('wsopen', onChange)
39
+
connection.addEventListener('wsclose', onChange)
40
+
connection.addEventListener('wserror', onChange)
41
+
connection$.value = connection
42
+
43
+
return () => {
44
+
connection.removeEventListener('wsopen', onChange)
45
+
connection.removeEventListener('wsclose', onChange)
46
+
connection.removeEventListener('wserror', onChange)
47
+
connection.destroy()
48
+
}
49
+
})
50
+
51
+
return (
52
+
<RealmConnectionContext.Provider
53
+
children={props.children}
54
+
value={{
55
+
realm: connection$,
56
+
realmid: realmid$,
57
+
connected: connected$,
58
+
}}
59
+
/>
60
+
)
61
+
}
+49
src/client/realm/context-identity.tsx
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import {generateSigningJwkPair} from '#common/crypto/jwks'
2
+
import {normalizeProtocolError, ProtocolError} from '#common/errors'
3
+
import {IdentBrand} from '#common/protocol'
4
+
import {createContext} from 'preact'
5
+
import {useEffect, useState} from 'preact/hooks'
6
+
import {RealmIdentity} from './identity'
7
+
8
+
export interface RealmIdentityValues {
9
+
identity: RealmIdentity
10
+
}
11
+
12
+
export interface RealmIdentityFallbackProps {
13
+
loading: boolean
14
+
error?: ProtocolError
15
+
}
16
+
17
+
export const RealmIdentityContext = createContext<RealmIdentityValues | null>(null)
18
+
19
+
export const RealmIdentityProvider: preact.FunctionComponent<{
20
+
fallback: (props: RealmIdentityFallbackProps) => preact.ComponentChild
21
+
children: preact.ComponentChildren
22
+
}> = (props) => {
23
+
const [identity$, setIdentity$] = useState<RealmIdentity>()
24
+
const [error$, setError$] = useState<ProtocolError>()
25
+
26
+
useEffect(() => {
27
+
if (identity$ || error$) return
28
+
29
+
generateSigningJwkPair()
30
+
.then((keypair) => {
31
+
const identid = IdentBrand.generate()
32
+
33
+
setError$(undefined)
34
+
setIdentity$(new RealmIdentity(identid, keypair))
35
+
})
36
+
.catch((exc: unknown) => {
37
+
setIdentity$(undefined)
38
+
setError$(normalizeProtocolError(exc))
39
+
})
40
+
}, [identity$, error$])
41
+
42
+
return identity$ ? (
43
+
<RealmIdentityContext.Provider value={{identity: identity$}}>
44
+
{props.children}
45
+
</RealmIdentityContext.Provider>
46
+
) : (
47
+
props.fallback({loading: !identity$, error: error$})
48
+
)
49
+
}
-51
src/client/realm/context.tsx
···
1
-
import {createContext} from 'preact'
2
-
import {useCallback, useEffect, useState} from 'preact/hooks'
3
-
4
-
import {RealmConnection} from '#client/realm/connection'
5
-
import {ConnectionIdentity} from './types'
6
-
7
-
interface RealmConnectionContext {
8
-
realm?: RealmConnection
9
-
identity?: ConnectionIdentity
10
-
setIdentity: (ident: ConnectionIdentity) => void
11
-
}
12
-
13
-
export const RealmConnectionContext = createContext<RealmConnectionContext | null>(null)
14
-
15
-
export const RealmConnectionProvider: preact.FunctionComponent<{
16
-
url: string
17
-
children: preact.ComponentChildren
18
-
}> = (props) => {
19
-
const [identity$, setIdentity$] = useState<ConnectionIdentity>()
20
-
const [connection$, setConnection$] = useState<RealmConnection>()
21
-
22
-
const connect = useCallback(() => {
23
-
if (connection$) return
24
-
if (!identity$) return
25
-
26
-
setConnection$(new RealmConnection(props.url, identity$))
27
-
}, [connection$, identity$, props.url])
28
-
29
-
const disconnect = useCallback(() => {
30
-
connection$?.destroy()
31
-
}, [connection$])
32
-
33
-
// connect on mount, or identity change
34
-
useEffect(() => {
35
-
if (identity$) connect()
36
-
37
-
// disconnect on unsubscribe
38
-
return disconnect
39
-
}, [connect, disconnect, identity$])
40
-
41
-
return (
42
-
<RealmConnectionContext.Provider
43
-
children={props.children}
44
-
value={{
45
-
realm: connection$,
46
-
identity: identity$,
47
-
setIdentity: setIdentity$,
48
-
}}
49
-
/>
50
-
)
51
-
}
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
+34
src/client/realm/identity.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import {JWK, jwkExport} from '#common/crypto/jwks.js'
2
+
import {IdentID} from '#common/protocol'
3
+
4
+
export interface Signable {
5
+
sign(key: CryptoKey): Promise<string>
6
+
}
7
+
8
+
/** manages websocket and webrtc connections for a realm */
9
+
export class RealmIdentity {
10
+
#identid: IdentID
11
+
#keypair: CryptoKeyPair
12
+
#pubjwk: Promise<JWK> | undefined
13
+
14
+
constructor(identid: IdentID, keypair: CryptoKeyPair) {
15
+
this.#identid = identid
16
+
this.#keypair = keypair
17
+
}
18
+
19
+
get identid() {
20
+
return this.#identid
21
+
}
22
+
23
+
get pubkey() {
24
+
return this.#keypair.publicKey
25
+
}
26
+
27
+
get pubjwk() {
28
+
return (this.#pubjwk ??= jwkExport.parseAsync(this.#keypair.publicKey))
29
+
}
30
+
31
+
sign(token: Signable) {
32
+
return token.sign(this.#keypair.privateKey)
33
+
}
34
+
}
-2
src/client/realm/types.ts
···
1
import {IdentID, RealmID} from '#common/protocol'
2
-
import SimplePeer from 'simple-peer'
3
4
/** identity info for connecting to a realm */
5
export interface ConnectionIdentity {
···
13
identid: IdentID
14
connected: boolean
15
destroyed: boolean
16
-
address: ReturnType<SimplePeer.Instance['address']>
17
}
···
1
import {IdentID, RealmID} from '#common/protocol'
0
2
3
/** identity info for connecting to a realm */
4
export interface ConnectionIdentity {
···
12
identid: IdentID
13
connected: boolean
14
destroyed: boolean
0
15
}
+34
-58
src/client/webrtc-demo.tsx
···
1
-
import {useCallback, useContext, useEffect} from 'preact/hooks'
0
2
3
-
import {Messenger} from '#client/components/messenger'
4
-
import {PeerList} from '#client/components/peer-list'
5
-
import {RealmConnectionContext} from '#client/realm/context'
6
-
7
-
import {generateSigningJwkPair} from '#common/crypto/jwks'
8
-
import * as protocol from '#common/protocol'
9
10
function attachEventListener<F extends EventListenerOrEventListenerObject>(
11
target: EventTarget,
···
17
}
18
19
export const WebRTCDemo: preact.FunctionComponent = () => {
20
-
const context = useContext(RealmConnectionContext)
21
-
if (!context) throw new Error('expected to be called inside realm connection context!')
22
23
-
useEffect(() => {
24
-
if (!context.realm) return
25
26
-
/* setup event listeners on the socket */
0
27
28
-
const wsopen = attachEventListener(context.realm, 'wsopen', () => {
0
0
29
console.log('connection socket open!')
30
})
31
32
-
const wsdata = attachEventListener(context.realm, 'wsdata', (e) => {
33
console.log('connection socket data!', e)
34
})
35
36
-
const wserror = attachEventListener(context.realm, 'wserror', (e) => {
37
console.log('connection socket error!', e)
38
})
39
40
-
const wsclose = attachEventListener(context.realm, 'wsclose', (e) => {
41
console.log('connection socket closed!', e)
42
})
43
44
-
const peeropen = attachEventListener(context.realm, 'peeropen', (p) => {
45
console.log('peer connected', p)
46
})
47
48
-
const peerdata = attachEventListener(context.realm, 'peerdata', (p) => {
49
console.log('peer data', p)
50
})
51
52
-
const peerclose = attachEventListener(context.realm, 'peerclose', (p) => {
53
console.log('peer disconnected', p)
54
})
55
56
-
const peererror = attachEventListener(context.realm, 'peererror', (p) => {
57
console.log('peer error', p)
58
})
59
60
return () => {
61
-
context.realm?.removeEventListener('wsopen', wsopen)
62
-
context.realm?.removeEventListener('wsdata', wsdata)
63
-
context.realm?.removeEventListener('wserror', wserror)
64
-
context.realm?.removeEventListener('wsclose', wsclose)
65
66
-
context.realm?.removeEventListener('peeropen', peeropen)
67
-
context.realm?.removeEventListener('peerdata', peerdata)
68
-
context.realm?.removeEventListener('peererror', peererror)
69
-
context.realm?.removeEventListener('peerclose', peerclose)
70
}
71
})
72
73
-
const connect = useCallback(() => {
74
-
const go = async () => {
75
-
const realmid = protocol.RealmBrand.parse('realm-n7-qM0rOzsJ8N-iF') // hard code for now
76
-
const identid = protocol.IdentBrand.generate()
77
-
const keypair = await generateSigningJwkPair()
78
-
79
-
context.setIdentity({realmid, identid, keypair})
80
-
}
81
-
82
-
go().catch((e: unknown) => {
83
-
console.error('couldnt create identity', e)
84
-
})
85
-
}, [context])
86
-
87
return (
88
<div className="webrtc-demo">
89
<h1>WebRTC Demo</h1>
90
91
-
<button onClick={connect}>Connect</button>
92
-
93
<div>
94
Identity:
95
-
<pre>{JSON.stringify(context.identity, null, 2)}</pre>
96
</div>
97
98
-
<div className="connection-info">
99
-
<p>
100
-
Status:
101
-
{context.realm?.connected ? '🟢 Connected' : '🔴 Disconnected'}
102
-
</p>
103
-
<pre>
104
-
<code>{JSON.stringify(context.realm, null, 2)}</code>
105
-
</pre>
106
-
</div>
107
-
108
-
{context.realm && (
109
<div className="demo-layout">
110
<div className="demo-section">
111
-
<PeerList webrtcManager={context.realm} />
112
-
<Messenger realm={context.realm} />
113
</div>
114
</div>
115
)}
···
1
+
import {useSignalEffect} from '@preact/signals'
2
+
import {useContext} from 'preact/hooks'
3
4
+
import {Messenger} from './components/messenger'
5
+
import {PeerList} from './components/peer-list'
6
+
import {RealmConnectionContext} from './realm/context-connection'
7
+
import {RealmIdentityContext} from './realm/context-identity'
0
0
8
9
function attachEventListener<F extends EventListenerOrEventListenerObject>(
10
target: EventTarget,
···
16
}
17
18
export const WebRTCDemo: preact.FunctionComponent = () => {
19
+
const identityContext = useContext(RealmIdentityContext)
20
+
if (!identityContext) throw new Error('expected to be called inside realm identity context!')
21
22
+
const connectionContext = useContext(RealmConnectionContext)
23
+
if (!connectionContext) throw new Error('expected to be called inside realm connection context!')
24
25
+
useSignalEffect(() => {
26
+
if (!connectionContext.realm.value) return
27
28
+
const realm = connectionContext.realm.value
29
+
30
+
const wsopen = attachEventListener(realm, 'wsopen', () => {
31
console.log('connection socket open!')
32
})
33
34
+
const wsdata = attachEventListener(realm, 'wsdata', (e) => {
35
console.log('connection socket data!', e)
36
})
37
38
+
const wserror = attachEventListener(realm, 'wserror', (e) => {
39
console.log('connection socket error!', e)
40
})
41
42
+
const wsclose = attachEventListener(realm, 'wsclose', (e) => {
43
console.log('connection socket closed!', e)
44
})
45
46
+
const peeropen = attachEventListener(realm, 'peeropen', (p) => {
47
console.log('peer connected', p)
48
})
49
50
+
const peerdata = attachEventListener(realm, 'peerdata', (p) => {
51
console.log('peer data', p)
52
})
53
54
+
const peerclose = attachEventListener(realm, 'peerclose', (p) => {
55
console.log('peer disconnected', p)
56
})
57
58
+
const peererror = attachEventListener(realm, 'peererror', (p) => {
59
console.log('peer error', p)
60
})
61
62
return () => {
63
+
realm.removeEventListener('wsopen', wsopen)
64
+
realm.removeEventListener('wsdata', wsdata)
65
+
realm.removeEventListener('wserror', wserror)
66
+
realm.removeEventListener('wsclose', wsclose)
67
68
+
realm.removeEventListener('peeropen', peeropen)
69
+
realm.removeEventListener('peerdata', peerdata)
70
+
realm.removeEventListener('peererror', peererror)
71
+
realm.removeEventListener('peerclose', peerclose)
72
}
73
})
74
0
0
0
0
0
0
0
0
0
0
0
0
0
0
75
return (
76
<div className="webrtc-demo">
77
<h1>WebRTC Demo</h1>
78
0
0
79
<div>
80
Identity:
81
+
<pre>{identityContext.identity?.identid}</pre>
82
</div>
83
84
+
{connectionContext.realm.value && (
0
0
0
0
0
0
0
0
0
0
85
<div className="demo-layout">
86
<div className="demo-section">
87
+
<PeerList webrtcManager={connectionContext.realm.value} />
88
+
<Messenger realm={connectionContext.realm.value} />
89
</div>
90
</div>
91
)}
+2
src/common/crypto/jwks.ts
···
2
import {z} from 'zod/v4'
3
import {CryptoError} from './errors'
4
0
0
5
const subtleSignAlgo = {name: 'ECDSA', namedCurve: 'P-256'}
6
const joseSignAlgo = {name: 'ES256'}
7
···
2
import {z} from 'zod/v4'
3
import {CryptoError} from './errors'
4
5
+
export type JWK = jose.JWK
6
+
7
const subtleSignAlgo = {name: 'ECDSA', namedCurve: 'P-256'}
8
const joseSignAlgo = {name: 'ES256'}
9
+2
-2
src/common/crypto/jwts.ts
···
5
6
const signAlgo = {name: 'ES256'}
7
8
-
interface JWTToken {
9
token: string
10
claims: jose.JWTPayload
11
}
12
13
-
interface JWTTokenPayload<T> {
14
token: string
15
claims: jose.JWTPayload
16
payload: T
···
5
6
const signAlgo = {name: 'ES256'}
7
8
+
export interface JWTToken {
9
token: string
10
claims: jose.JWTPayload
11
}
12
13
+
export interface JWTTokenPayload<T> {
14
token: string
15
claims: jose.JWTPayload
16
payload: T
+14
-1
src/common/protocol/messages.ts
···
19
20
export const preauthAuthnReqSchema = makeEmptyRequestSchema('preauth.authn')
21
0
0
0
0
0
0
0
0
22
export const preauthRespSchema = makeResponseSchema(
23
'preauth.authn',
24
z.object({
···
26
}),
27
)
28
29
-
export const preauthReqSchema = z.union([preauthAuthnReqSchema, preauthRegisterReqSchema])
0
0
0
0
30
0
31
export type PreauthRegisterRequest = z.infer<typeof preauthRegisterReqSchema>
32
export type PreauthAuthnRequest = z.infer<typeof preauthAuthnReqSchema>
33
export type PreauthResponse = z.infer<typeof preauthRespSchema>
···
19
20
export const preauthAuthnReqSchema = makeEmptyRequestSchema('preauth.authn')
21
22
+
export const preauthExchangeInviteReqSchema = makeRequestSchema(
23
+
'preauth.exchange',
24
+
z.object({
25
+
pubkey: jwkSchema,
26
+
inviteJwt: z.jwt(),
27
+
}),
28
+
)
29
+
30
export const preauthRespSchema = makeResponseSchema(
31
'preauth.authn',
32
z.object({
···
34
}),
35
)
36
37
+
export const preauthReqSchema = z.union([
38
+
preauthAuthnReqSchema,
39
+
preauthExchangeInviteReqSchema,
40
+
preauthRegisterReqSchema,
41
+
])
42
43
+
export type PreauthExchangeInviteRequest = z.infer<typeof preauthExchangeInviteReqSchema>
44
export type PreauthRegisterRequest = z.infer<typeof preauthRegisterReqSchema>
45
export type PreauthAuthnRequest = z.infer<typeof preauthAuthnReqSchema>
46
export type PreauthResponse = z.infer<typeof preauthRespSchema>
+38
-5
src/server/routes-socket/handler-preauth.ts
···
2
3
import {combineSignals, timeoutSignal} from '#common/async/aborts'
4
import {jwkImport} from '#common/crypto/jwks'
5
-
import {jwtPayload, verifyJwtToken} from '#common/crypto/jwts'
6
import {normalizeError, ProtocolError} from '#common/errors'
7
import {
8
IdentBrand,
···
36
const identid = IdentBrand.parse(jwt.claims.iss)
37
const realmid = RealmBrand.parse(jwt.claims.aud)
38
39
-
// if we're registering, make sure the realm exists
40
-
if (jwt.payload.msg === 'preauth.register') {
41
-
const registrantkey = await jwkImport.parseAsync(jwt.payload.dat.pubkey)
42
-
realms.ensureRegisteredRealm(realmid, identid, registrantkey)
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
43
}
44
0
45
const auth = await authenticatePreauth(realmid, identid, jwt.token)
46
const msg = preauthResponse(auth, jwt.payload.seq)
47
ws.send(JSON.stringify(msg))
···
49
return auth
50
} finally {
51
timeout.cancel()
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
52
}
53
}
54
···
2
3
import {combineSignals, timeoutSignal} from '#common/async/aborts'
4
import {jwkImport} from '#common/crypto/jwks'
5
+
import {jwtPayload, jwtSchema, JWTToken, verifyJwtToken} from '#common/crypto/jwts'
6
import {normalizeError, ProtocolError} from '#common/errors'
7
import {
8
IdentBrand,
···
36
const identid = IdentBrand.parse(jwt.claims.iss)
37
const realmid = RealmBrand.parse(jwt.claims.aud)
38
39
+
switch (jwt.payload.msg) {
40
+
case 'preauth.register': {
41
+
// if we're registering, make sure the realm exists
42
+
const registrantkey = await jwkImport.parseAsync(jwt.payload.dat.pubkey)
43
+
realms.ensureRegisteredRealm(realmid, identid, registrantkey)
44
+
45
+
break
46
+
}
47
+
48
+
case 'preauth.exchange': {
49
+
// validate and then insert (validation throws)
50
+
const token = jwtSchema.parse(jwt.payload.dat.inviteJwt)
51
+
await preauthValidateInvitation(realmid, token)
52
+
53
+
const inviteeid = IdentBrand.parse(jwt.claims.iss)
54
+
const inviteekey = await jwkImport.parseAsync(jwt.payload.dat.pubkey)
55
+
realms.admitToRealm(realmid, inviteeid, inviteekey)
56
+
57
+
break
58
+
}
59
}
60
61
+
// everything is in place, fall through to authentication
62
const auth = await authenticatePreauth(realmid, identid, jwt.token)
63
const msg = preauthResponse(auth, jwt.payload.seq)
64
ws.send(JSON.stringify(msg))
···
66
return auth
67
} finally {
68
timeout.cancel()
69
+
}
70
+
}
71
+
72
+
async function preauthValidateInvitation(realmid: RealmID, invitation: JWTToken): Promise<void> {
73
+
try {
74
+
const realm = realms.realmMap.require(realmid)
75
+
76
+
if (!invitation.claims.jti) throw 'invitation requires nonce!'
77
+
if (!realms.validateNonce(realmid, invitation.claims.jti)) throw 'invitation already used!'
78
+
79
+
const inviterid = IdentBrand.parse(invitation.claims.iss)
80
+
const inviterkey = realm.identities.require(inviterid)
81
+
await verifyJwtToken(invitation.token, inviterkey, {subject: 'invitation'})
82
+
} catch (exc) {
83
+
const err = normalizeError(exc)
84
+
throw new ProtocolError('invitation verification failed', 401, {cause: err})
85
}
86
}
87
+17
src/server/routes-socket/state.ts
···
13
14
export interface Realm {
15
realmid: RealmID
0
16
sockets: StrictMap<IdentID, WebSocket[]>
17
identities: StrictMap<IdentID, CryptoKey>
18
}
···
36
): Realm {
37
const realm = realmMap.ensure(realmid, () => ({
38
realmid,
0
39
sockets: new StrictMap(),
40
identities: new StrictMap([[registrantid, registrantkey]]),
41
}))
···
43
// hack for now, allow any registration to work
44
realm.identities.ensure(registrantid, () => registrantkey)
45
return realm
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
46
}
47
48
export function attachSocket(realm: Realm, ident: IdentID, socket: WebSocket) {
···
13
14
export interface Realm {
15
realmid: RealmID
16
+
nonces: Set<string>
17
sockets: StrictMap<IdentID, WebSocket[]>
18
identities: StrictMap<IdentID, CryptoKey>
19
}
···
37
): Realm {
38
const realm = realmMap.ensure(realmid, () => ({
39
realmid,
40
+
nonces: new Set(),
41
sockets: new StrictMap(),
42
identities: new StrictMap([[registrantid, registrantkey]]),
43
}))
···
45
// hack for now, allow any registration to work
46
realm.identities.ensure(registrantid, () => registrantkey)
47
return realm
48
+
}
49
+
50
+
export function validateNonce(realmid: RealmID, nonce: string): boolean {
51
+
const realm = realmMap.require(realmid)
52
+
if (realm.nonces.has(nonce)) {
53
+
return false
54
+
}
55
+
56
+
realm.nonces.add(nonce)
57
+
return true
58
+
}
59
+
60
+
export function admitToRealm(realmid: RealmID, inviteeid: IdentID, inviteekey: CryptoKey) {
61
+
const realm = realmMap.require(realmid)
62
+
realm.identities.set(inviteeid, inviteekey)
63
}
64
65
export function attachSocket(realm: Realm, ident: IdentID, socket: WebSocket) {