tangled
alpha
login
or
join now
accidental.cc
/
skypod
3
fork
atom
podcast manager
3
fork
atom
overview
issues
pulls
pipelines
add peer capabilities announcements
Jonathan Raphaelson
4 months ago
844d5e02
e72126a9
+248
-31
8 changed files
expand all
collapse all
unified
split
src
client
realm
service-connection-peer.ts
service-connection-sync.ts
service-connection.ts
service-device.ts
common
protocol
device.ts
messages.ts
server
routes-socket
handler-realm.ts
state.ts
+93
-15
src/client/realm/service-connection-peer.ts
···
1
1
import SimplePeer from 'simple-peer'
2
2
+
import z from 'zod/v4'
2
3
4
4
+
import {BlockingQueue} from '#common/async/blocking-queue'
5
5
+
import {sleep} from '#common/async/sleep'
3
6
import * as protocol from '#common/protocol'
4
7
import {IdentID} from '#common/protocol'
8
8
+
import {DeviceCaps, DeviceInfo} from '#common/protocol/device'
5
9
6
6
-
import {BlockingQueue} from '#common/async/blocking-queue'
7
7
-
import {sleep} from '#common/async/sleep'
8
8
-
import z from 'zod/v4'
9
10
import {RealmSyncManager} from './service-connection-sync'
10
11
11
12
const realmRtcAutohandleSchema = z.union([
12
13
protocol.realmRtcPingRequestSchema,
13
14
protocol.realmRtcPongResponseSchema,
15
15
+
protocol.realmRtcAnnounceRequestSchema,
16
16
+
protocol.realmRtcAnnounceResponseSchema,
14
17
])
15
18
16
19
/** a single webrtc peer connection within a realm */
···
23
26
#queue: BlockingQueue<unknown>
24
27
#abort: AbortController
25
28
29
29
+
#announced = false
30
30
+
#peercaps?: DeviceCaps
31
31
+
#peerinfo?: DeviceInfo
32
32
+
26
33
constructor(sync: RealmSyncManager, identid: IdentID, initiator: boolean) {
27
34
super({
28
35
initiator,
···
58
65
}
59
66
60
67
sendJson<T extends unknown>(data: T) {
61
61
-
console.debug('sending:', this.identid, data)
68
68
+
this.send(JSON.stringify(data))
69
69
+
}
62
70
63
63
-
this.send(JSON.stringify(data))
71
71
+
get deviceCaps() {
72
72
+
return this.#peercaps
73
73
+
}
74
74
+
get deviceInfo() {
75
75
+
return this.#peerinfo
64
76
}
65
77
66
78
#dispatch(type: string, detail?: object) {
···
140
152
case 'realm.rtc.pong':
141
153
await this.#receivePong(parsed.data)
142
154
continue
155
155
+
156
156
+
case 'realm.rtc.announce':
157
157
+
switch (parsed.data.typ) {
158
158
+
case 'req':
159
159
+
await this.#receiveAnnounceReq(parsed.data)
160
160
+
continue
161
161
+
162
162
+
case 'res':
163
163
+
await this.#receiveAnnounceRes(parsed.data)
164
164
+
continue
165
165
+
}
143
166
}
144
167
}
145
168
}
146
169
170
170
+
#receiveAnnounceReq = async (ping: protocol.RealmRtcAnnounceRequest) => {
171
171
+
const peerClocks = await this.#sync.buildSyncState()
172
172
+
const {deviceInfo, deviceCaps} = this.#sync
173
173
+
174
174
+
// reply to pings with pongs
175
175
+
this.sendJson<protocol.RealmRtcAnnounceResponse>({
176
176
+
typ: 'res',
177
177
+
msg: 'realm.rtc.announce',
178
178
+
seq: ping.seq,
179
179
+
dat: {peerClocks, deviceInfo, deviceCaps},
180
180
+
})
181
181
+
182
182
+
// explicit sync requested
183
183
+
if (ping.dat.peerRequestsSync) {
184
184
+
const actions = await this.#sync.buildSyncDelta(ping.dat.peerClocks)
185
185
+
if (actions.length) {
186
186
+
this.sendJson(actions.map((a) => a.action))
187
187
+
}
188
188
+
}
189
189
+
190
190
+
this.#peercaps = ping.dat.deviceCaps
191
191
+
this.#peerinfo = ping.dat.deviceInfo
192
192
+
this.#dispatch('peerannounce', {identid: this.identid})
193
193
+
}
194
194
+
195
195
+
// when a peer responds to a ping (lazy sync)
196
196
+
#receiveAnnounceRes = async (pong: protocol.RealmRtcAnnounceResponse) => {
197
197
+
const actions = await this.#sync.buildSyncDelta(pong.dat.peerClocks)
198
198
+
if (actions.length) {
199
199
+
this.sendJson(actions.map((a) => a.action))
200
200
+
}
201
201
+
202
202
+
this.#peercaps = pong.dat.deviceCaps
203
203
+
this.#peerinfo = pong.dat.deviceInfo
204
204
+
205
205
+
this.#dispatch('peerannounce', {identid: this.identid})
206
206
+
}
207
207
+
147
208
#receivePing = async (ping: protocol.RealmRtcPingRequest) => {
148
209
const peerClocks = await this.#sync.buildSyncState()
149
210
···
181
242
console.debug('ping loop running for:', this.identid)
182
243
183
244
if (this.initiator) {
245
245
+
await sleep(250)
184
246
await this.ping()
185
247
}
186
248
···
197
259
const peerClocks = await this.#sync.buildSyncState()
198
260
const peerRequestsSync = this.identid === this.#sync.chooseSyncPeer(this.#sync.knownPeers)
199
261
200
200
-
console.log('sending ping to: ', this.identid, peerClocks, peerRequestsSync)
201
201
-
this.sendJson<protocol.RealmRtcPingRequest>({
202
202
-
typ: 'req',
203
203
-
msg: 'realm.rtc.ping',
204
204
-
seq: this.#seq++,
205
205
-
dat: {
206
206
-
peerClocks,
207
207
-
peerRequestsSync,
208
208
-
},
209
209
-
})
262
262
+
const announce = !this.#announced || this.#sync.deviceChanged
263
263
+
if (announce) {
264
264
+
const {deviceInfo, deviceCaps} = this.#sync
265
265
+
this.#announced = true
266
266
+
this.sendJson<protocol.RealmRtcAnnounceRequest>({
267
267
+
typ: 'req',
268
268
+
msg: 'realm.rtc.announce',
269
269
+
seq: this.#seq++,
270
270
+
dat: {
271
271
+
peerClocks,
272
272
+
peerRequestsSync,
273
273
+
deviceCaps,
274
274
+
deviceInfo,
275
275
+
},
276
276
+
})
277
277
+
} else {
278
278
+
this.sendJson<protocol.RealmRtcPingRequest>({
279
279
+
typ: 'req',
280
280
+
msg: 'realm.rtc.ping',
281
281
+
seq: this.#seq++,
282
282
+
dat: {
283
283
+
peerClocks,
284
284
+
peerRequestsSync,
285
285
+
},
286
286
+
})
287
287
+
}
210
288
}
211
289
}
+13
src/client/realm/service-connection-sync.ts
···
1
1
import {Database, StoredAction} from '#client/root/service-database'
2
2
import {IdentID} from '#common/protocol'
3
3
import {LCTimestamp, LogicalClock} from '#common/protocol/logical-clock'
4
4
+
import {DeviceScanner} from './service-device'
4
5
5
6
export class RealmSyncManager {
6
7
#db: Database
7
8
#clock: LogicalClock
8
9
#identid: IdentID
9
10
#peers: Map<IdentID, unknown>
11
11
+
#device: DeviceScanner
10
12
11
13
// objects are shared with realm connection
12
14
···
15
17
this.#clock = clock
16
18
this.#identid = identid
17
19
this.#peers = peers
20
20
+
this.#device = new DeviceScanner()
18
21
}
19
22
20
23
get knownPeers() {
21
24
return Array.from(this.#peers.keys())
25
25
+
}
26
26
+
27
27
+
get deviceInfo() {
28
28
+
return this.#device.deviceInfo
29
29
+
}
30
30
+
get deviceCaps() {
31
31
+
return this.#device.deviceCaps
32
32
+
}
33
33
+
get deviceChanged() {
34
34
+
return false
22
35
}
23
36
24
37
chooseSyncPeer(peerids: IdentID[]): IdentID | null {
+24
-14
src/client/realm/service-connection.ts
···
44
44
45
45
#identity: RealmIdentity
46
46
#sync: RealmSyncManager
47
47
+
#announced = false
47
48
48
49
#serverseq = 0
49
50
#serversync = false
···
92
93
}
93
94
94
95
async requestSync() {
96
96
+
this.#announced = false
97
97
+
95
98
const promises = [this.#pingSocket()]
96
99
for (const peer of this.#peers.values()) {
97
100
promises.push(peer.ping())
···
121
124
}
122
125
123
126
broadcast(data: unknown, self = false) {
124
124
-
console.debug('broadcasting:', self, data)
125
125
-
126
127
const json = JSON.stringify(data)
127
128
128
129
this.#peers.forEach((peer, identid) => {
···
130
131
})
131
132
132
133
if (this.#serversync) {
133
133
-
console.debug('sending to server:', self, data)
134
134
this.#socket.send(json)
135
135
}
136
136
}
137
137
138
138
destroy() {
139
139
-
console.debug('realm connection destroy!')
140
140
-
139
139
+
console.log('realm connection destroy!')
141
140
if (this.connected) {
142
141
this.#socket.close()
143
142
}
···
249
248
continue
250
249
}
251
250
252
252
-
console.debug('connecting...:', peerid)
251
251
+
console.log('connecting to peer...:', peerid)
253
252
this.#connectPeer(peerid, true)
254
253
}
255
254
···
332
331
return
333
332
334
333
case 'realm.rtc.pong': {
335
335
-
console.debug('got a pong response from the server', parse)
336
336
-
337
334
const actions = await this.#sync.buildSyncDelta(parse.data.dat.peerClocks)
338
335
if (actions.length) {
339
336
this.#socket.send(JSON.stringify(actions.map((a) => a.action)))
···
368
365
369
366
async #pingSocket() {
370
367
const peerClocks = await this.#sync.buildSyncState()
371
371
-
this.#socketSend<protocol.RealmRtcPingRequest>({
372
372
-
typ: 'req',
373
373
-
msg: 'realm.rtc.ping',
374
374
-
seq: this.#serverseq++,
375
375
-
dat: {peerClocks, peerRequestsSync: true},
376
376
-
})
368
368
+
369
369
+
const announce = !this.#announced || this.#sync.deviceChanged
370
370
+
if (announce) {
371
371
+
const {deviceInfo, deviceCaps} = this.#sync
372
372
+
this.#announced = true
373
373
+
this.#socketSend<protocol.RealmRtcAnnounceRequest>({
374
374
+
typ: 'req',
375
375
+
msg: 'realm.rtc.announce',
376
376
+
seq: this.#serverseq++,
377
377
+
dat: {peerClocks, peerRequestsSync: true, deviceCaps, deviceInfo},
378
378
+
})
379
379
+
} else {
380
380
+
this.#socketSend<protocol.RealmRtcPingRequest>({
381
381
+
typ: 'req',
382
382
+
msg: 'realm.rtc.ping',
383
383
+
seq: this.#serverseq++,
384
384
+
dat: {peerClocks, peerRequestsSync: true},
385
385
+
})
386
386
+
}
377
387
}
378
388
379
389
// peers
+27
src/client/realm/service-device.ts
···
1
1
+
import {DeviceCaps, DeviceInfo} from '#common/protocol/device'
2
2
+
3
3
+
export class DeviceScanner {
4
4
+
#caps: DeviceCaps
5
5
+
#info: DeviceInfo
6
6
+
7
7
+
// some day do detection, if we ever actually care
8
8
+
9
9
+
constructor() {
10
10
+
this.#info = {
11
11
+
ua: window.navigator.userAgent,
12
12
+
}
13
13
+
14
14
+
this.#caps = {
15
15
+
corsFetch: false,
16
16
+
networkQuality: undefined,
17
17
+
}
18
18
+
}
19
19
+
20
20
+
get deviceCaps() {
21
21
+
return this.#caps
22
22
+
}
23
23
+
24
24
+
get deviceInfo() {
25
25
+
return this.#info
26
26
+
}
27
27
+
}
+16
src/common/protocol/device.ts
···
1
1
+
import {z} from 'zod/v4'
2
2
+
3
3
+
export const deviceCapsSchema = z.object({
4
4
+
corsFetch: z.boolean().default(false),
5
5
+
networkQuality: z.int().positive().lte(5).optional(),
6
6
+
})
7
7
+
8
8
+
export const deviceInfoSchema = z.object({
9
9
+
ua: z.string().optional(),
10
10
+
name: z.string().optional(),
11
11
+
battery: z.boolean().optional(),
12
12
+
metered: z.boolean().optional(),
13
13
+
})
14
14
+
15
15
+
export type DeviceCaps = z.infer<typeof deviceCapsSchema>
16
16
+
export type DeviceInfo = z.infer<typeof deviceInfoSchema>
+29
-2
src/common/protocol/messages.ts
···
2
2
import {z} from 'zod/v4'
3
3
4
4
import {IdentBrand} from './brands'
5
5
+
import {deviceCapsSchema, deviceInfoSchema} from './device'
5
6
import {LogicalClock} from './logical-clock'
6
7
import {
7
8
makeEmptyRequestSchema,
···
12
13
13
14
export const serverPeerIdSchema = z.literal('server')
14
15
export type ServerPeerId = z.infer<typeof serverPeerIdSchema>
16
16
+
17
17
+
export const peerClocksSchema = z.record(z.string(), LogicalClock.schema.nullable())
15
18
16
19
/// preauth
17
20
···
62
65
export const realmRtcPingRequestSchema = makeRequestSchema(
63
66
'realm.rtc.ping',
64
67
z.object({
65
65
-
peerClocks: z.record(z.string(), LogicalClock.schema.nullable()),
68
68
+
peerClocks: peerClocksSchema,
66
69
peerRequestsSync: z.boolean(),
67
70
}),
68
71
)
69
72
70
73
export const realmRtcPongResponseSchema = makeResponseSchema(
71
74
'realm.rtc.pong',
75
75
+
z.object({peerClocks: peerClocksSchema}),
76
76
+
)
77
77
+
78
78
+
export const realmRtcAnnounceRequestSchema = makeRequestSchema(
79
79
+
'realm.rtc.announce',
72
80
z.object({
73
73
-
peerClocks: z.record(z.string(), LogicalClock.schema.nullable()),
81
81
+
// like a ping
82
82
+
peerClocks: peerClocksSchema,
83
83
+
peerRequestsSync: z.boolean(),
84
84
+
85
85
+
// but with caps and device info
86
86
+
deviceCaps: deviceCapsSchema.optional(),
87
87
+
deviceInfo: deviceInfoSchema.optional(),
88
88
+
}),
89
89
+
)
90
90
+
91
91
+
export const realmRtcAnnounceResponseSchema = makeResponseSchema(
92
92
+
'realm.rtc.announce',
93
93
+
z.object({
94
94
+
peerClocks: peerClocksSchema,
95
95
+
deviceCaps: deviceCapsSchema.optional(),
96
96
+
deviceInfo: deviceInfoSchema.optional(),
74
97
}),
75
98
)
76
99
77
100
export const realmRtcPingPongMessageSchema = z.union([
101
101
+
realmRtcAnnounceRequestSchema,
102
102
+
realmRtcAnnounceResponseSchema,
78
103
realmRtcPingRequestSchema,
79
104
realmRtcPongResponseSchema,
80
105
])
···
114
139
)
115
140
116
141
export type RealmBroadcastEvent = z.infer<typeof realmBroadcastEventSchema>
142
142
+
export type RealmRtcAnnounceRequest = z.infer<typeof realmRtcAnnounceRequestSchema>
143
143
+
export type RealmRtcAnnounceResponse = z.infer<typeof realmRtcAnnounceResponseSchema>
117
144
export type RealmRtcPingRequest = z.infer<typeof realmRtcPingRequestSchema>
118
145
export type RealmRtcPongResponse = z.infer<typeof realmRtcPongResponseSchema>
119
146
export type RealmRtcSignalEvent = z.infer<typeof realmRtcSignalEventSchema>
+43
src/server/routes-socket/handler-realm.ts
···
14
14
protocol.realmBroadcastEventSchema,
15
15
protocol.realmRtcSignalEventSchema,
16
16
protocol.realmRtcPingRequestSchema,
17
17
+
protocol.realmRtcAnnounceRequestSchema,
17
18
z.array(actionMessageSchema),
18
19
])
19
20
···
49
50
50
51
case 'realm.rtc.ping':
51
52
await socketPeerPing(ws, auth, data)
53
53
+
continue
54
54
+
55
55
+
case 'realm.rtc.announce':
56
56
+
await socketPeerAnnounce(ws, auth, data)
52
57
continue
53
58
54
59
default:
···
146
151
}
147
152
}
148
153
}
154
154
+
155
155
+
async function socketPeerAnnounce(
156
156
+
ws: WebSocket,
157
157
+
auth: realm.AuthenticatedIdentity,
158
158
+
announce: protocol.RealmRtcAnnounceRequest,
159
159
+
) {
160
160
+
console.log('announce from', auth.identid, announce.dat)
161
161
+
162
162
+
auth.deviceCaps = announce.dat.deviceCaps
163
163
+
auth.deviceInfo = announce.dat.deviceInfo
164
164
+
165
165
+
const peerClocks = await auth.realm.storage.buildSyncState()
166
166
+
const response: protocol.RealmRtcAnnounceResponse = {
167
167
+
typ: 'res',
168
168
+
msg: 'realm.rtc.announce',
169
169
+
seq: announce.seq,
170
170
+
dat: {
171
171
+
peerClocks,
172
172
+
deviceCaps: {
173
173
+
corsFetch: true,
174
174
+
networkQuality: 5,
175
175
+
},
176
176
+
deviceInfo: {
177
177
+
ua: process.env.SERVER_UA || 'skypod-realm',
178
178
+
name: process.env.SERVER_NAME || 'Skypod Server',
179
179
+
},
180
180
+
},
181
181
+
}
182
182
+
ws.send(JSON.stringify(response))
183
183
+
184
184
+
if (announce.dat.peerRequestsSync) {
185
185
+
const actions = await auth.realm.storage.buildSyncDelta(announce.dat.peerClocks)
186
186
+
if (actions.length) {
187
187
+
const actionsJson = actions.map((a) => a.action)
188
188
+
ws.send(JSON.stringify(actionsJson))
189
189
+
}
190
190
+
}
191
191
+
}
+3
src/server/routes-socket/state.ts
···
1
1
import WebSocket from 'isomorphic-ws'
2
2
3
3
import {IdentID, RealmID} from '#common/protocol'
4
4
+
import {DeviceCaps, DeviceInfo} from '#common/protocol/device'
4
5
import {StrictMap} from '#common/strict-map'
5
6
6
7
import {RealmStorage} from '#server/realm-storage'
···
11
12
realmid: RealmID
12
13
identid: IdentID
13
14
pubkey: CryptoKey
15
15
+
deviceCaps?: DeviceCaps
16
16
+
deviceInfo?: DeviceInfo
14
17
}
15
18
16
19
export interface Realm {