podcast manager

add peer capabilities announcements

+248 -31
+93 -15
src/client/realm/service-connection-peer.ts
··· 1 1 import SimplePeer from 'simple-peer' 2 + import z from 'zod/v4' 2 3 4 + import {BlockingQueue} from '#common/async/blocking-queue' 5 + import {sleep} from '#common/async/sleep' 3 6 import * as protocol from '#common/protocol' 4 7 import {IdentID} from '#common/protocol' 8 + import {DeviceCaps, DeviceInfo} from '#common/protocol/device' 5 9 6 - import {BlockingQueue} from '#common/async/blocking-queue' 7 - import {sleep} from '#common/async/sleep' 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 + protocol.realmRtcAnnounceRequestSchema, 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 + #announced = false 30 + #peercaps?: DeviceCaps 31 + #peerinfo?: DeviceInfo 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 - console.debug('sending:', this.identid, data) 68 + this.send(JSON.stringify(data)) 69 + } 62 70 63 - this.send(JSON.stringify(data)) 71 + get deviceCaps() { 72 + return this.#peercaps 73 + } 74 + get deviceInfo() { 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 + 156 + case 'realm.rtc.announce': 157 + switch (parsed.data.typ) { 158 + case 'req': 159 + await this.#receiveAnnounceReq(parsed.data) 160 + continue 161 + 162 + case 'res': 163 + await this.#receiveAnnounceRes(parsed.data) 164 + continue 165 + } 143 166 } 144 167 } 145 168 } 146 169 170 + #receiveAnnounceReq = async (ping: protocol.RealmRtcAnnounceRequest) => { 171 + const peerClocks = await this.#sync.buildSyncState() 172 + const {deviceInfo, deviceCaps} = this.#sync 173 + 174 + // reply to pings with pongs 175 + this.sendJson<protocol.RealmRtcAnnounceResponse>({ 176 + typ: 'res', 177 + msg: 'realm.rtc.announce', 178 + seq: ping.seq, 179 + dat: {peerClocks, deviceInfo, deviceCaps}, 180 + }) 181 + 182 + // explicit sync requested 183 + if (ping.dat.peerRequestsSync) { 184 + const actions = await this.#sync.buildSyncDelta(ping.dat.peerClocks) 185 + if (actions.length) { 186 + this.sendJson(actions.map((a) => a.action)) 187 + } 188 + } 189 + 190 + this.#peercaps = ping.dat.deviceCaps 191 + this.#peerinfo = ping.dat.deviceInfo 192 + this.#dispatch('peerannounce', {identid: this.identid}) 193 + } 194 + 195 + // when a peer responds to a ping (lazy sync) 196 + #receiveAnnounceRes = async (pong: protocol.RealmRtcAnnounceResponse) => { 197 + const actions = await this.#sync.buildSyncDelta(pong.dat.peerClocks) 198 + if (actions.length) { 199 + this.sendJson(actions.map((a) => a.action)) 200 + } 201 + 202 + this.#peercaps = pong.dat.deviceCaps 203 + this.#peerinfo = pong.dat.deviceInfo 204 + 205 + this.#dispatch('peerannounce', {identid: this.identid}) 206 + } 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 + 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 - console.log('sending ping to: ', this.identid, peerClocks, peerRequestsSync) 201 - this.sendJson<protocol.RealmRtcPingRequest>({ 202 - typ: 'req', 203 - msg: 'realm.rtc.ping', 204 - seq: this.#seq++, 205 - dat: { 206 - peerClocks, 207 - peerRequestsSync, 208 - }, 209 - }) 262 + const announce = !this.#announced || this.#sync.deviceChanged 263 + if (announce) { 264 + const {deviceInfo, deviceCaps} = this.#sync 265 + this.#announced = true 266 + this.sendJson<protocol.RealmRtcAnnounceRequest>({ 267 + typ: 'req', 268 + msg: 'realm.rtc.announce', 269 + seq: this.#seq++, 270 + dat: { 271 + peerClocks, 272 + peerRequestsSync, 273 + deviceCaps, 274 + deviceInfo, 275 + }, 276 + }) 277 + } else { 278 + this.sendJson<protocol.RealmRtcPingRequest>({ 279 + typ: 'req', 280 + msg: 'realm.rtc.ping', 281 + seq: this.#seq++, 282 + dat: { 283 + peerClocks, 284 + peerRequestsSync, 285 + }, 286 + }) 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 + 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 + #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 + this.#device = new DeviceScanner() 18 21 } 19 22 20 23 get knownPeers() { 21 24 return Array.from(this.#peers.keys()) 25 + } 26 + 27 + get deviceInfo() { 28 + return this.#device.deviceInfo 29 + } 30 + get deviceCaps() { 31 + return this.#device.deviceCaps 32 + } 33 + get deviceChanged() { 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 + #announced = false 47 48 48 49 #serverseq = 0 49 50 #serversync = false ··· 92 93 } 93 94 94 95 async requestSync() { 96 + this.#announced = false 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 - console.debug('broadcasting:', self, data) 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 - console.debug('sending to server:', self, data) 134 134 this.#socket.send(json) 135 135 } 136 136 } 137 137 138 138 destroy() { 139 - console.debug('realm connection destroy!') 140 - 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 - console.debug('connecting...:', peerid) 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 - console.debug('got a pong response from the server', parse) 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 - this.#socketSend<protocol.RealmRtcPingRequest>({ 372 - typ: 'req', 373 - msg: 'realm.rtc.ping', 374 - seq: this.#serverseq++, 375 - dat: {peerClocks, peerRequestsSync: true}, 376 - }) 368 + 369 + const announce = !this.#announced || this.#sync.deviceChanged 370 + if (announce) { 371 + const {deviceInfo, deviceCaps} = this.#sync 372 + this.#announced = true 373 + this.#socketSend<protocol.RealmRtcAnnounceRequest>({ 374 + typ: 'req', 375 + msg: 'realm.rtc.announce', 376 + seq: this.#serverseq++, 377 + dat: {peerClocks, peerRequestsSync: true, deviceCaps, deviceInfo}, 378 + }) 379 + } else { 380 + this.#socketSend<protocol.RealmRtcPingRequest>({ 381 + typ: 'req', 382 + msg: 'realm.rtc.ping', 383 + seq: this.#serverseq++, 384 + dat: {peerClocks, peerRequestsSync: true}, 385 + }) 386 + } 377 387 } 378 388 379 389 // peers
+27
src/client/realm/service-device.ts
··· 1 + import {DeviceCaps, DeviceInfo} from '#common/protocol/device' 2 + 3 + export class DeviceScanner { 4 + #caps: DeviceCaps 5 + #info: DeviceInfo 6 + 7 + // some day do detection, if we ever actually care 8 + 9 + constructor() { 10 + this.#info = { 11 + ua: window.navigator.userAgent, 12 + } 13 + 14 + this.#caps = { 15 + corsFetch: false, 16 + networkQuality: undefined, 17 + } 18 + } 19 + 20 + get deviceCaps() { 21 + return this.#caps 22 + } 23 + 24 + get deviceInfo() { 25 + return this.#info 26 + } 27 + }
+16
src/common/protocol/device.ts
··· 1 + import {z} from 'zod/v4' 2 + 3 + export const deviceCapsSchema = z.object({ 4 + corsFetch: z.boolean().default(false), 5 + networkQuality: z.int().positive().lte(5).optional(), 6 + }) 7 + 8 + export const deviceInfoSchema = z.object({ 9 + ua: z.string().optional(), 10 + name: z.string().optional(), 11 + battery: z.boolean().optional(), 12 + metered: z.boolean().optional(), 13 + }) 14 + 15 + export type DeviceCaps = z.infer<typeof deviceCapsSchema> 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 + 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 + 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 - peerClocks: z.record(z.string(), LogicalClock.schema.nullable()), 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 + z.object({peerClocks: peerClocksSchema}), 76 + ) 77 + 78 + export const realmRtcAnnounceRequestSchema = makeRequestSchema( 79 + 'realm.rtc.announce', 72 80 z.object({ 73 - peerClocks: z.record(z.string(), LogicalClock.schema.nullable()), 81 + // like a ping 82 + peerClocks: peerClocksSchema, 83 + peerRequestsSync: z.boolean(), 84 + 85 + // but with caps and device info 86 + deviceCaps: deviceCapsSchema.optional(), 87 + deviceInfo: deviceInfoSchema.optional(), 88 + }), 89 + ) 90 + 91 + export const realmRtcAnnounceResponseSchema = makeResponseSchema( 92 + 'realm.rtc.announce', 93 + z.object({ 94 + peerClocks: peerClocksSchema, 95 + deviceCaps: deviceCapsSchema.optional(), 96 + deviceInfo: deviceInfoSchema.optional(), 74 97 }), 75 98 ) 76 99 77 100 export const realmRtcPingPongMessageSchema = z.union([ 101 + realmRtcAnnounceRequestSchema, 102 + realmRtcAnnounceResponseSchema, 78 103 realmRtcPingRequestSchema, 79 104 realmRtcPongResponseSchema, 80 105 ]) ··· 114 139 ) 115 140 116 141 export type RealmBroadcastEvent = z.infer<typeof realmBroadcastEventSchema> 142 + export type RealmRtcAnnounceRequest = z.infer<typeof realmRtcAnnounceRequestSchema> 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 + 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 + continue 54 + 55 + case 'realm.rtc.announce': 56 + await socketPeerAnnounce(ws, auth, data) 52 57 continue 53 58 54 59 default: ··· 146 151 } 147 152 } 148 153 } 154 + 155 + async function socketPeerAnnounce( 156 + ws: WebSocket, 157 + auth: realm.AuthenticatedIdentity, 158 + announce: protocol.RealmRtcAnnounceRequest, 159 + ) { 160 + console.log('announce from', auth.identid, announce.dat) 161 + 162 + auth.deviceCaps = announce.dat.deviceCaps 163 + auth.deviceInfo = announce.dat.deviceInfo 164 + 165 + const peerClocks = await auth.realm.storage.buildSyncState() 166 + const response: protocol.RealmRtcAnnounceResponse = { 167 + typ: 'res', 168 + msg: 'realm.rtc.announce', 169 + seq: announce.seq, 170 + dat: { 171 + peerClocks, 172 + deviceCaps: { 173 + corsFetch: true, 174 + networkQuality: 5, 175 + }, 176 + deviceInfo: { 177 + ua: process.env.SERVER_UA || 'skypod-realm', 178 + name: process.env.SERVER_NAME || 'Skypod Server', 179 + }, 180 + }, 181 + } 182 + ws.send(JSON.stringify(response)) 183 + 184 + if (announce.dat.peerRequestsSync) { 185 + const actions = await auth.realm.storage.buildSyncDelta(announce.dat.peerClocks) 186 + if (actions.length) { 187 + const actionsJson = actions.map((a) => a.action) 188 + ws.send(JSON.stringify(actionsJson)) 189 + } 190 + } 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 + 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 + deviceCaps?: DeviceCaps 16 + deviceInfo?: DeviceInfo 14 17 } 15 18 16 19 export interface Realm {