tempest-react-sdk 0.55.0 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -73
- package/dist/components/AppBar/AppBar.module.cjs.map +1 -1
- package/dist/components/AppBar/AppBar.module.js.map +1 -1
- package/dist/components/AudioPlayer/AudioPlayer.cjs +1 -1
- package/dist/components/AudioPlayer/AudioPlayer.js +6 -6
- package/dist/components/Scheduler/Scheduler.module.cjs.map +1 -1
- package/dist/components/Scheduler/Scheduler.module.js.map +1 -1
- package/dist/components/VideoPlayer/VideoPlayer.cjs +2 -0
- package/dist/components/VideoPlayer/VideoPlayer.cjs.map +1 -0
- package/dist/components/VideoPlayer/VideoPlayer.js +229 -0
- package/dist/components/VideoPlayer/VideoPlayer.js.map +1 -0
- package/dist/components/VideoPlayer/VideoPlayer.module.cjs +2 -0
- package/dist/components/VideoPlayer/VideoPlayer.module.cjs.map +1 -0
- package/dist/components/VideoPlayer/VideoPlayer.module.js +19 -0
- package/dist/components/VideoPlayer/VideoPlayer.module.js.map +1 -0
- package/dist/components/VideoPlayer/playback-rates.cjs +2 -0
- package/dist/components/VideoPlayer/playback-rates.cjs.map +1 -0
- package/dist/components/VideoPlayer/playback-rates.js +11 -0
- package/dist/components/VideoPlayer/playback-rates.js.map +1 -0
- package/dist/hooks/use-push-to-talk.cjs +1 -1
- package/dist/hooks/use-push-to-talk.cjs.map +1 -1
- package/dist/hooks/use-push-to-talk.js +2 -2
- package/dist/hooks/use-push-to-talk.js.map +1 -1
- package/dist/imaging/exceptions.cjs +1 -1
- package/dist/imaging/exceptions.cjs.map +1 -1
- package/dist/imaging/exceptions.js +5 -1
- package/dist/imaging/exceptions.js.map +1 -1
- package/dist/imaging/frame.cjs +2 -0
- package/dist/imaging/frame.cjs.map +1 -0
- package/dist/imaging/frame.js +82 -0
- package/dist/imaging/frame.js.map +1 -0
- package/dist/imaging.cjs +1 -1
- package/dist/imaging.d.ts +611 -494
- package/dist/imaging.js +10 -9
- package/dist/styles/AppBar.css +1 -1
- package/dist/styles/Scheduler.css +2 -2
- package/dist/styles/VideoPlayer.css +23 -0
- package/dist/styles/core.css +1 -1
- package/dist/styles/layout.css +2 -2
- package/dist/styles/media.css +22 -0
- package/dist/styles/navigation.css +1 -1
- package/dist/styles.css +1 -1
- package/dist/tempest-react-sdk.cjs +1 -1
- package/dist/tempest-react-sdk.d.ts +317 -35
- package/dist/tempest-react-sdk.js +213 -211
- package/dist/webrtc/link-stats.cjs +1 -1
- package/dist/webrtc/link-stats.cjs.map +1 -1
- package/dist/webrtc/link-stats.js +134 -44
- package/dist/webrtc/link-stats.js.map +1 -1
- package/dist/webrtc/mesh-quality.cjs +1 -1
- package/dist/webrtc/mesh-quality.cjs.map +1 -1
- package/dist/webrtc/mesh-quality.js +8 -2
- package/dist/webrtc/mesh-quality.js.map +1 -1
- package/dist/webrtc/peer-mesh.cjs +1 -1
- package/dist/webrtc/peer-mesh.cjs.map +1 -1
- package/dist/webrtc/peer-mesh.js +85 -63
- package/dist/webrtc/peer-mesh.js.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"peer-mesh.cjs","names":[],"sources":["../../src/webrtc/peer-mesh.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines — the mesh is one object's lifetime: a link's\n * transceivers, the offer it owes, the tracks published on it and the share of\n * the uplink it costs all change together, and every method here reads or writes\n * the same two maps. The pieces that *could* stand alone already do —\n * `peer-link.ts` for the per-connection mechanics and `mesh-quality.ts` for the\n * division — and what is left is the coordination between them.\n */\nimport { applyQualityToLink, resolveDegradation, scaleForRoom } from \"./mesh-quality\";\nimport {\n acceptCandidate,\n adoptTransceivers,\n attachLocalTracks,\n createPeerConnection,\n drainCandidates,\n slotOf,\n type PeerLink,\n} from \"./peer-link\";\nimport type { MeshMessage, MeshPeer, MeshQuality, PeerMeshOptions } from \"./mesh-types\";\n\n/** A running mesh. */\nexport interface PeerMesh {\n /**\n * Open a link to a peer.\n *\n * `offerer` decides which side sends the offer, and exactly one side of each\n * link must say `true`. Drive it from the arrival order your server already\n * imposes — the peer that joined **later** offers, the one already in the\n * room answers. That avoids glare by construction, without the rollback\n * dance of perfect negotiation and without depending on a browser\n * implementing argument-less `setLocalDescription`.\n */\n addPeer: (peerId: string, options: { offerer: boolean }) => Promise<void>;\n /** Close one link and give its share of the uplink back to the others. */\n removePeer: (peerId: string) => void;\n /** Feed the mesh an `offer`, `answer` or `ice` message from `from`. */\n accept: (message: MeshMessage & { from: string }) => Promise<void>;\n /**\n * Publish or retract one slot across every live link.\n *\n * `null` retracts: the sender keeps its negotiated transceiver and stops\n * producing, which is what mutes a microphone without disturbing the other\n * slots or the connection. Nothing renegotiates — the m-lines were allocated\n * before any track existed, which is what makes a mesh survive toggling: a\n * renegotiation per toggle is N-1 simultaneous offer/answer rounds every time\n * somebody unmutes.\n */\n setLocalTrack: (slot: string, track: MediaStreamTrack | null) => Promise<void>;\n /** Apply encoder limits, divided by the current size of the room. */\n applyQuality: (quality: MeshQuality) => Promise<void>;\n /** Replace the ICE servers used for links opened from now on. */\n setIceServers: (servers: RTCIceServer[]) => void;\n /** Close every link and report `closed`. */\n stop: () => void;\n /** The peers as the mesh currently sees them. */\n readonly peers: MeshPeer[];\n}\n\n/**\n * One `RTCPeerConnection` per peer, with the slots negotiated up front.\n *\n * The signalling protocol stays yours: this produces and accepts three message\n * shapes (`offer`, `answer`, `ice`) and leaves delivery, room membership and\n * identity to your server. What it owns is the part that is the same in every\n * mesh and wrong in most of them — see {@link PeerMesh} for the traps each\n * method exists to avoid.\n *\n * @param options - See {@link PeerMeshOptions}.\n * @returns The mesh.\n *\n * @example\n * const mesh = createPeerMesh({\n * slots: [\n * { name: \"mic\", kind: \"audio\" },\n * { name: \"cam\", kind: \"video\" },\n * ],\n * send: (message) => socket.send(message),\n * onPeers: setPeers,\n * });\n * socket.onMessage = (message) => void mesh.accept(message);\n * await mesh.addPeer(\"peer-2\", { offerer: true });\n * await mesh.setLocalTrack(\"mic\", micTrack);\n */\nexport function createPeerMesh(options: PeerMeshOptions): PeerMesh {\n const { slots, send } = options;\n const links = new Map<string, PeerLink>();\n const peers = new Map<string, MeshPeer>();\n const localTracks = new Map<string, MediaStreamTrack | null>();\n let iceServers = options.iceServers ?? [];\n const describe =\n options.setLocalDescription ??\n ((connection: RTCPeerConnection, description: RTCSessionDescriptionInit) =>\n connection.setLocalDescription(description));\n let quality: MeshQuality = options.quality ?? {};\n let stopped = false;\n\n const emptyStreams = (): Record<string, MediaStream | null> =>\n Object.fromEntries(slots.map((slot) => [slot.name, null]));\n\n const emitPeers = (): void => {\n options.onPeers?.([...peers.values()].map((peer) => ({ ...peer })));\n };\n\n /**\n * Collapse the per-link states into the one badge a call shows.\n *\n * No links at all is `connected`, not a failure: it means nobody else is in\n * the room yet, which is a legitimate state a call sits in for as long as the\n * first person is early.\n */\n const refreshState = (): void => {\n if (stopped) return;\n const states = [...links.values()].map((link) => link.pc.connectionState);\n if (states.some((state) => state === \"connected\")) options.onState?.(\"connected\");\n else if (states.length > 0) options.onState?.(\"connecting\", \"negotiating\");\n else options.onState?.(\"connected\", \"alone\");\n };\n\n const applyQuality = async (next: MeshQuality): Promise<void> => {\n quality = next;\n const effective = scaleForRoom(next, links.size);\n const degradation = resolveDegradation(next, effective);\n for (const link of links.values()) {\n await applyQualityToLink(link.transceivers, slots, effective, degradation);\n }\n };\n\n const makeOffer = async (link: PeerLink): Promise<void> => {\n if (link.makingOffer) return;\n link.makingOffer = true;\n try {\n const offer = await link.pc.createOffer();\n if (!offer.sdp) {\n options.onNotice?.(\"empty_local_offer_sdp\");\n return;\n }\n await describe(link.pc, offer);\n send({\n type: \"offer\",\n to: link.peerId,\n sdp: link.pc.localDescription?.sdp ?? offer.sdp,\n });\n } catch {\n options.onNotice?.(\"offer_failed\");\n } finally {\n link.makingOffer = false;\n }\n };\n\n /**\n * Wrap an inbound track in the stream the caller renders.\n *\n * The event's own stream is preferred: it is the one the sending side\n * grouped the track into, so a caller that reads `stream.id` sees what the\n * peer published rather than a wrapper invented here. Browsers only fill it\n * when the offer named a stream, hence the fallback — and the guard after\n * that is for a runtime with no `MediaStream` at all, where routing still\n * has to not throw.\n */\n const streamFor = (event: RTCTrackEvent): MediaStream | null => {\n const provided = event.streams?.[0];\n if (provided) return provided;\n return typeof MediaStream === \"undefined\" ? null : new MediaStream([event.track]);\n };\n\n const attachRemoteTrack = (link: PeerLink, event: RTCTrackEvent): void => {\n const slot = slotOf(event.transceiver, link, slots);\n const peer = peers.get(link.peerId);\n if (!slot || !peer) return;\n\n peer.streams = { ...peer.streams, [slot.name]: streamFor(event) };\n event.track.onended = (): void => {\n const current = peers.get(link.peerId);\n if (!current) return;\n current.streams = { ...current.streams, [slot.name]: null };\n emitPeers();\n };\n emitPeers();\n };\n\n const removePeer = (peerId: string): void => {\n const link = links.get(peerId);\n if (link) {\n link.pc.close();\n links.delete(peerId);\n }\n peers.delete(peerId);\n void applyQuality(quality);\n emitPeers();\n refreshState();\n };\n\n const addPeer = async (peerId: string, { offerer }: { offerer: boolean }): Promise<void> => {\n if (links.has(peerId)) return;\n\n const pc = createPeerConnection(iceServers);\n const link: PeerLink = {\n peerId,\n pc,\n transceivers: [],\n pendingCandidates: [],\n remoteReady: false,\n makingOffer: false,\n isOfferer: offerer,\n };\n links.set(peerId, link);\n peers.set(peerId, {\n peerId,\n connection: pc.connectionState,\n streams: peers.get(peerId)?.streams ?? emptyStreams(),\n });\n\n if (offerer) {\n link.transceivers = slots.map((slot) =>\n pc.addTransceiver(slot.kind, { direction: \"sendrecv\" }),\n );\n await attachLocalTracks(link, slots, localTracks);\n }\n\n pc.onicecandidate = (event): void => {\n send({\n type: \"ice\",\n to: peerId,\n candidate: event.candidate?.candidate ?? null,\n sdpMid: event.candidate?.sdpMid ?? null,\n sdpMLineIndex: event.candidate?.sdpMLineIndex ?? null,\n });\n };\n\n pc.ontrack = (event): void => attachRemoteTrack(link, event);\n\n pc.onconnectionstatechange = (): void => {\n const peer = peers.get(peerId);\n if (peer) peer.connection = pc.connectionState;\n if (pc.connectionState === \"failed\" || pc.connectionState === \"closed\") {\n removePeer(peerId);\n return;\n }\n emitPeers();\n refreshState();\n };\n\n pc.onnegotiationneeded = (): void => {\n if (link.isOfferer) void makeOffer(link);\n };\n\n emitPeers();\n refreshState();\n if (offerer) await makeOffer(link);\n };\n\n const acceptOffer = async (from: string, sdp: string): Promise<void> => {\n if (!links.has(from)) await addPeer(from, { offerer: false });\n const link = links.get(from);\n if (!link) return;\n\n await link.pc.setRemoteDescription({ type: \"offer\", sdp });\n link.remoteReady = true;\n await drainCandidates(link);\n\n if (!link.isOfferer) {\n adoptTransceivers(link);\n await attachLocalTracks(link, slots, localTracks);\n }\n\n const answer = await link.pc.createAnswer();\n if (!answer.sdp) {\n options.onNotice?.(\"empty_local_answer_sdp\");\n return;\n }\n await describe(link.pc, answer);\n send({ type: \"answer\", to: from, sdp: link.pc.localDescription?.sdp ?? answer.sdp });\n await applyQuality(quality);\n };\n\n const accept = async (message: MeshMessage & { from: string }): Promise<void> => {\n if (message.type === \"offer\") {\n await acceptOffer(message.from, message.sdp);\n return;\n }\n\n const link = links.get(message.from);\n if (!link) return;\n\n if (message.type === \"answer\") {\n await link.pc.setRemoteDescription({ type: \"answer\", sdp: message.sdp });\n link.remoteReady = true;\n await drainCandidates(link);\n await applyQuality(quality);\n return;\n }\n\n if (message.candidate === null) return;\n await acceptCandidate(link, {\n candidate: message.candidate,\n sdpMid: message.sdpMid ?? undefined,\n sdpMLineIndex: message.sdpMLineIndex ?? undefined,\n });\n };\n\n const setLocalTrack = async (slot: string, track: MediaStreamTrack | null): Promise<void> => {\n localTracks.set(slot, track);\n const index = slots.findIndex((entry) => entry.name === slot);\n if (index < 0) return;\n\n for (const link of links.values()) {\n const transceiver = link.transceivers[index];\n if (!transceiver) continue;\n try {\n await transceiver.sender.replaceTrack(track);\n } catch {\n /* the link is tearing down; its state handler will remove it */\n }\n }\n await applyQuality(quality);\n };\n\n const stop = (): void => {\n for (const link of links.values()) link.pc.close();\n links.clear();\n peers.clear();\n stopped = true;\n emitPeers();\n options.onState?.(\"closed\");\n };\n\n options.onState?.(\"connecting\", \"signaling\");\n\n return {\n addPeer,\n removePeer,\n accept,\n setLocalTrack,\n applyQuality,\n setIceServers: (servers): void => {\n iceServers = servers;\n },\n stop,\n get peers(): MeshPeer[] {\n return [...peers.values()].map((peer) => ({ ...peer }));\n },\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"peer-mesh.cjs","names":[],"sources":["../../src/webrtc/peer-mesh.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines — the mesh is one object's lifetime: a link's\n * transceivers, the offer it owes, the tracks published on it and the share of\n * the uplink it costs all change together, and every method here reads or writes\n * the same two maps. The pieces that *could* stand alone already do —\n * `peer-link.ts` for the per-connection mechanics and `mesh-quality.ts` for the\n * division — and what is left is the coordination between them.\n */\nimport { applyQualityToLink, resolveDegradation, scaleForRoom } from \"./mesh-quality\";\nimport {\n acceptCandidate,\n adoptTransceivers,\n attachLocalTracks,\n createPeerConnection,\n drainCandidates,\n slotOf,\n type PeerLink,\n} from \"./peer-link\";\nimport { createLinkStatsSampler, type LinkStatsSampler } from \"./link-stats\";\nimport type {\n MeshMessage,\n MeshPeer,\n MeshQuality,\n MeshStatsOptions,\n PeerMeshOptions,\n} from \"./mesh-types\";\n\n/** A running mesh. */\nexport interface PeerMesh {\n /**\n * Open a link to a peer.\n *\n * `offerer` decides which side sends the offer, and exactly one side of each\n * link must say `true`. Drive it from the arrival order your server already\n * imposes — the peer that joined **later** offers, the one already in the\n * room answers. That avoids glare by construction, without the rollback\n * dance of perfect negotiation and without depending on a browser\n * implementing argument-less `setLocalDescription`.\n */\n addPeer: (peerId: string, options: { offerer: boolean }) => Promise<void>;\n /** Close one link and give its share of the uplink back to the others. */\n removePeer: (peerId: string) => void;\n /** Feed the mesh an `offer`, `answer` or `ice` message from `from`. */\n accept: (message: MeshMessage & { from: string }) => Promise<void>;\n /**\n * Publish or retract one slot across every live link.\n *\n * `null` retracts: the sender keeps its negotiated transceiver and stops\n * producing, which is what mutes a microphone without disturbing the other\n * slots or the connection. Nothing renegotiates — the m-lines were allocated\n * before any track existed, which is what makes a mesh survive toggling: a\n * renegotiation per toggle is N-1 simultaneous offer/answer rounds every time\n * somebody unmutes.\n */\n setLocalTrack: (slot: string, track: MediaStreamTrack | null) => Promise<void>;\n /** Apply encoder limits, divided by the current size of the room. */\n applyQuality: (quality: MeshQuality) => Promise<void>;\n /** Replace the ICE servers used for links opened from now on. */\n setIceServers: (servers: RTCIceServer[]) => void;\n /** Close every link and report `closed`. */\n stop: () => void;\n /** The peers as the mesh currently sees them. */\n readonly peers: MeshPeer[];\n /**\n * The connection carrying one peer's link, or `null`.\n *\n * The escape hatch for everything this mesh does not model. `getStats()`,\n * `getSenders()`, an `RTCDataChannel`, an encoding tweak the `quality`\n * shape has no field for — all of it lives on the connection, and a mesh\n * that keeps it private turns adopting the mesh into losing a feature.\n *\n * {@link MeshPeer.connection} is the *state*, which is what a view needs;\n * this is the object, which is what a measurement needs. For the common\n * case of measuring, {@link PeerMeshOptions.stats} spares you the loop —\n * including the one-sampler-per-connection rule that a hand-rolled version\n * gets wrong.\n *\n * @param peerId - The id the peer was added under.\n * @returns The connection, or `null` when there is no link for that id.\n */\n getConnection: (peerId: string) => RTCPeerConnection | null;\n}\n\n/**\n * One `RTCPeerConnection` per peer, with the slots negotiated up front.\n *\n * The signalling protocol stays yours: this produces and accepts three message\n * shapes (`offer`, `answer`, `ice`) and leaves delivery, room membership and\n * identity to your server. What it owns is the part that is the same in every\n * mesh and wrong in most of them — see {@link PeerMesh} for the traps each\n * method exists to avoid.\n *\n * @param options - See {@link PeerMeshOptions}.\n * @returns The mesh.\n *\n * @example\n * const mesh = createPeerMesh({\n * slots: [\n * { name: \"mic\", kind: \"audio\" },\n * { name: \"cam\", kind: \"video\" },\n * ],\n * send: (message) => socket.send(message),\n * onPeers: setPeers,\n * });\n * socket.onMessage = (message) => void mesh.accept(message);\n * await mesh.addPeer(\"peer-2\", { offerer: true });\n * await mesh.setLocalTrack(\"mic\", micTrack);\n */\nexport function createPeerMesh(options: PeerMeshOptions): PeerMesh {\n const { slots, send } = options;\n const links = new Map<string, PeerLink>();\n const peers = new Map<string, MeshPeer>();\n const localTracks = new Map<string, MediaStreamTrack | null>();\n let iceServers = options.iceServers ?? [];\n const describe =\n options.setLocalDescription ??\n ((connection: RTCPeerConnection, description: RTCSessionDescriptionInit) =>\n connection.setLocalDescription(description));\n let quality: MeshQuality = options.quality ?? {};\n let stopped = false;\n const samplers = new Map<string, LinkStatsSampler>();\n let statsTimer: ReturnType<typeof setInterval> | null = null;\n\n const emptyStreams = (): Record<string, MediaStream | null> =>\n Object.fromEntries(slots.map((slot) => [slot.name, null]));\n\n const emitPeers = (): void => {\n options.onPeers?.([...peers.values()].map((peer) => ({ ...peer })));\n };\n\n /**\n * Sample every connected link once.\n *\n * Only `connected` links are asked. A link still gathering candidates has\n * no traffic to measure, and asking anyway spends a `getStats()` round trip\n * to learn that — per link, on every tick.\n *\n * `settings` arrives as an argument rather than being read from `options`\n * here: the timer is only ever armed past the check that they exist, so\n * reading them again would add a branch that nothing can reach.\n *\n * @param settings - The measuring options, already known to be present.\n */\n const sampleLinks = async (settings: MeshStatsOptions): Promise<void> => {\n if (stopped) return;\n let changed = false;\n\n for (const link of [...links.values()]) {\n if (link.pc.connectionState !== \"connected\") continue;\n let sampler = samplers.get(link.peerId);\n if (sampler === undefined) {\n sampler = createLinkStatsSampler(\n settings.kind === undefined ? {} : { kind: settings.kind },\n );\n samplers.set(link.peerId, sampler);\n }\n const stats = await sampler.sample(link.pc);\n const peer = peers.get(link.peerId);\n /*\n * Belt and braces, and the belt is the state check above: a peer\n * removed from inside `onStats` has its connection closed first, so\n * the next iteration skips it there rather than here. This catches\n * the entry disappearing any other way across the await.\n */\n if (peer === undefined) continue;\n peer.stats = stats;\n changed = true;\n settings.onStats?.(link.peerId, stats);\n }\n\n if (changed) emitPeers();\n };\n\n /**\n * Keep the timer alive exactly while there is something to measure.\n *\n * One timer for the whole mesh, not one per peer — and none at all in an\n * empty room, which is where a call sits for as long as the first person is\n * early.\n */\n const syncStatsTimer = (): void => {\n const settings = options.stats;\n if (settings === undefined) return;\n const wanted = !stopped && links.size > 0;\n if (wanted && statsTimer === null) {\n statsTimer = setInterval(() => void sampleLinks(settings), settings.intervalMs ?? 2000);\n return;\n }\n if (!wanted && statsTimer !== null) {\n clearInterval(statsTimer);\n statsTimer = null;\n }\n };\n\n /**\n * Collapse the per-link states into the one badge a call shows.\n *\n * No links at all is `connected`, not a failure: it means nobody else is in\n * the room yet, which is a legitimate state a call sits in for as long as the\n * first person is early.\n */\n const refreshState = (): void => {\n if (stopped) return;\n const states = [...links.values()].map((link) => link.pc.connectionState);\n if (states.some((state) => state === \"connected\")) options.onState?.(\"connected\");\n else if (states.length > 0) options.onState?.(\"connecting\", \"negotiating\");\n else options.onState?.(\"connected\", \"alone\");\n };\n\n const applyQuality = async (next: MeshQuality): Promise<void> => {\n quality = next;\n const effective = scaleForRoom(next, links.size);\n const degradation = resolveDegradation(next, effective);\n for (const link of links.values()) {\n await applyQualityToLink(link.transceivers, slots, effective, degradation);\n }\n };\n\n const makeOffer = async (link: PeerLink): Promise<void> => {\n if (link.makingOffer) return;\n link.makingOffer = true;\n try {\n const offer = await link.pc.createOffer();\n if (!offer.sdp) {\n options.onNotice?.(\"empty_local_offer_sdp\");\n return;\n }\n await describe(link.pc, offer);\n send({\n type: \"offer\",\n to: link.peerId,\n sdp: link.pc.localDescription?.sdp ?? offer.sdp,\n });\n } catch {\n options.onNotice?.(\"offer_failed\");\n } finally {\n link.makingOffer = false;\n }\n };\n\n /**\n * Wrap an inbound track in the stream the caller renders.\n *\n * The event's own stream is preferred: it is the one the sending side\n * grouped the track into, so a caller that reads `stream.id` sees what the\n * peer published rather than a wrapper invented here. Browsers only fill it\n * when the offer named a stream, hence the fallback — and the guard after\n * that is for a runtime with no `MediaStream` at all, where routing still\n * has to not throw.\n */\n const streamFor = (event: RTCTrackEvent): MediaStream | null => {\n const provided = event.streams?.[0];\n if (provided) return provided;\n return typeof MediaStream === \"undefined\" ? null : new MediaStream([event.track]);\n };\n\n const attachRemoteTrack = (link: PeerLink, event: RTCTrackEvent): void => {\n const slot = slotOf(event.transceiver, link, slots);\n const peer = peers.get(link.peerId);\n if (!slot || !peer) return;\n\n peer.streams = { ...peer.streams, [slot.name]: streamFor(event) };\n event.track.onended = (): void => {\n const current = peers.get(link.peerId);\n if (!current) return;\n current.streams = { ...current.streams, [slot.name]: null };\n emitPeers();\n };\n emitPeers();\n };\n\n const removePeer = (peerId: string): void => {\n const link = links.get(peerId);\n if (link) {\n link.pc.close();\n links.delete(peerId);\n }\n peers.delete(peerId);\n samplers.delete(peerId);\n void applyQuality(quality);\n syncStatsTimer();\n emitPeers();\n refreshState();\n };\n\n const addPeer = async (peerId: string, { offerer }: { offerer: boolean }): Promise<void> => {\n if (links.has(peerId)) return;\n\n const pc = createPeerConnection(iceServers);\n const link: PeerLink = {\n peerId,\n pc,\n transceivers: [],\n pendingCandidates: [],\n remoteReady: false,\n makingOffer: false,\n isOfferer: offerer,\n };\n links.set(peerId, link);\n peers.set(peerId, {\n peerId,\n connection: pc.connectionState,\n streams: peers.get(peerId)?.streams ?? emptyStreams(),\n });\n\n if (offerer) {\n link.transceivers = slots.map((slot) =>\n pc.addTransceiver(slot.kind, { direction: \"sendrecv\" }),\n );\n await attachLocalTracks(link, slots, localTracks);\n }\n\n pc.onicecandidate = (event): void => {\n send({\n type: \"ice\",\n to: peerId,\n candidate: event.candidate?.candidate ?? null,\n sdpMid: event.candidate?.sdpMid ?? null,\n sdpMLineIndex: event.candidate?.sdpMLineIndex ?? null,\n });\n };\n\n pc.ontrack = (event): void => attachRemoteTrack(link, event);\n\n pc.onconnectionstatechange = (): void => {\n const peer = peers.get(peerId);\n if (peer) peer.connection = pc.connectionState;\n if (pc.connectionState === \"failed\" || pc.connectionState === \"closed\") {\n removePeer(peerId);\n return;\n }\n emitPeers();\n refreshState();\n };\n\n pc.onnegotiationneeded = (): void => {\n if (link.isOfferer) void makeOffer(link);\n };\n\n syncStatsTimer();\n emitPeers();\n refreshState();\n if (offerer) await makeOffer(link);\n };\n\n const acceptOffer = async (from: string, sdp: string): Promise<void> => {\n if (!links.has(from)) await addPeer(from, { offerer: false });\n const link = links.get(from);\n if (!link) return;\n\n await link.pc.setRemoteDescription({ type: \"offer\", sdp });\n link.remoteReady = true;\n await drainCandidates(link);\n\n if (!link.isOfferer) {\n adoptTransceivers(link);\n await attachLocalTracks(link, slots, localTracks);\n }\n\n const answer = await link.pc.createAnswer();\n if (!answer.sdp) {\n options.onNotice?.(\"empty_local_answer_sdp\");\n return;\n }\n await describe(link.pc, answer);\n send({ type: \"answer\", to: from, sdp: link.pc.localDescription?.sdp ?? answer.sdp });\n await applyQuality(quality);\n };\n\n const accept = async (message: MeshMessage & { from: string }): Promise<void> => {\n if (message.type === \"offer\") {\n await acceptOffer(message.from, message.sdp);\n return;\n }\n\n const link = links.get(message.from);\n if (!link) return;\n\n if (message.type === \"answer\") {\n await link.pc.setRemoteDescription({ type: \"answer\", sdp: message.sdp });\n link.remoteReady = true;\n await drainCandidates(link);\n await applyQuality(quality);\n return;\n }\n\n if (message.candidate === null) return;\n await acceptCandidate(link, {\n candidate: message.candidate,\n sdpMid: message.sdpMid ?? undefined,\n sdpMLineIndex: message.sdpMLineIndex ?? undefined,\n });\n };\n\n const setLocalTrack = async (slot: string, track: MediaStreamTrack | null): Promise<void> => {\n localTracks.set(slot, track);\n const index = slots.findIndex((entry) => entry.name === slot);\n if (index < 0) return;\n\n for (const link of links.values()) {\n const transceiver = link.transceivers[index];\n if (!transceiver) continue;\n try {\n await transceiver.sender.replaceTrack(track);\n } catch {\n /* the link is tearing down; its state handler will remove it */\n }\n }\n await applyQuality(quality);\n };\n\n const stop = (): void => {\n for (const link of links.values()) link.pc.close();\n links.clear();\n peers.clear();\n samplers.clear();\n stopped = true;\n syncStatsTimer();\n emitPeers();\n options.onState?.(\"closed\");\n };\n\n options.onState?.(\"connecting\", \"signaling\");\n\n return {\n addPeer,\n removePeer,\n accept,\n setLocalTrack,\n applyQuality,\n setIceServers: (servers): void => {\n iceServers = servers;\n },\n stop,\n getConnection: (peerId: string): RTCPeerConnection | null => links.get(peerId)?.pc ?? null,\n get peers(): MeshPeer[] {\n return [...peers.values()].map((peer) => ({ ...peer }));\n },\n };\n}\n"],"mappings":"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"}
|
package/dist/webrtc/peer-mesh.js
CHANGED
|
@@ -1,55 +1,76 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createLinkStatsSampler as e } from "./link-stats.js";
|
|
2
|
+
import { applyQualityToLink as t, resolveDegradation as n, scaleForRoom as r } from "./mesh-quality.js";
|
|
3
|
+
import { acceptCandidate as i, adoptTransceivers as a, attachLocalTracks as o, createPeerConnection as s, drainCandidates as c, slotOf as l } from "./peer-link.js";
|
|
3
4
|
//#region src/webrtc/peer-mesh.ts
|
|
4
|
-
function
|
|
5
|
-
let { slots:
|
|
6
|
-
|
|
7
|
-
},
|
|
8
|
-
if (
|
|
9
|
-
let
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
5
|
+
function u(u) {
|
|
6
|
+
let { slots: d, send: f } = u, p = /* @__PURE__ */ new Map(), m = /* @__PURE__ */ new Map(), h = /* @__PURE__ */ new Map(), g = u.iceServers ?? [], _ = u.setLocalDescription ?? ((e, t) => e.setLocalDescription(t)), v = u.quality ?? {}, y = !1, b = /* @__PURE__ */ new Map(), x = null, S = () => Object.fromEntries(d.map((e) => [e.name, null])), C = () => {
|
|
7
|
+
u.onPeers?.([...m.values()].map((e) => ({ ...e })));
|
|
8
|
+
}, w = async (t) => {
|
|
9
|
+
if (y) return;
|
|
10
|
+
let n = !1;
|
|
11
|
+
for (let r of [...p.values()]) {
|
|
12
|
+
if (r.pc.connectionState !== "connected") continue;
|
|
13
|
+
let i = b.get(r.peerId);
|
|
14
|
+
i === void 0 && (i = e(t.kind === void 0 ? {} : { kind: t.kind }), b.set(r.peerId, i));
|
|
15
|
+
let a = await i.sample(r.pc), o = m.get(r.peerId);
|
|
16
|
+
o !== void 0 && (o.stats = a, n = !0, t.onStats?.(r.peerId, a));
|
|
17
|
+
}
|
|
18
|
+
n && C();
|
|
19
|
+
}, T = () => {
|
|
20
|
+
let e = u.stats;
|
|
21
|
+
if (e === void 0) return;
|
|
22
|
+
let t = !y && p.size > 0;
|
|
23
|
+
if (t && x === null) {
|
|
24
|
+
x = setInterval(() => void w(e), e.intervalMs ?? 2e3);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
!t && x !== null && (clearInterval(x), x = null);
|
|
28
|
+
}, E = () => {
|
|
29
|
+
if (y) return;
|
|
30
|
+
let e = [...p.values()].map((e) => e.pc.connectionState);
|
|
31
|
+
e.some((e) => e === "connected") ? u.onState?.("connected") : e.length > 0 ? u.onState?.("connecting", "negotiating") : u.onState?.("connected", "alone");
|
|
32
|
+
}, D = async (e) => {
|
|
33
|
+
v = e;
|
|
34
|
+
let i = r(e, p.size), a = n(e, i);
|
|
35
|
+
for (let e of p.values()) await t(e.transceivers, d, i, a);
|
|
36
|
+
}, O = async (e) => {
|
|
16
37
|
if (!e.makingOffer) {
|
|
17
38
|
e.makingOffer = !0;
|
|
18
39
|
try {
|
|
19
40
|
let t = await e.pc.createOffer();
|
|
20
41
|
if (!t.sdp) {
|
|
21
|
-
|
|
42
|
+
u.onNotice?.("empty_local_offer_sdp");
|
|
22
43
|
return;
|
|
23
44
|
}
|
|
24
|
-
await
|
|
45
|
+
await _(e.pc, t), f({
|
|
25
46
|
type: "offer",
|
|
26
47
|
to: e.peerId,
|
|
27
48
|
sdp: e.pc.localDescription?.sdp ?? t.sdp
|
|
28
49
|
});
|
|
29
50
|
} catch {
|
|
30
|
-
|
|
51
|
+
u.onNotice?.("offer_failed");
|
|
31
52
|
} finally {
|
|
32
53
|
e.makingOffer = !1;
|
|
33
54
|
}
|
|
34
55
|
}
|
|
35
|
-
},
|
|
36
|
-
let n =
|
|
56
|
+
}, k = (e) => e.streams?.[0] || (typeof MediaStream > "u" ? null : new MediaStream([e.track])), A = (e, t) => {
|
|
57
|
+
let n = l(t.transceiver, e, d), r = m.get(e.peerId);
|
|
37
58
|
!n || !r || (r.streams = {
|
|
38
59
|
...r.streams,
|
|
39
|
-
[n.name]:
|
|
60
|
+
[n.name]: k(t)
|
|
40
61
|
}, t.track.onended = () => {
|
|
41
|
-
let t =
|
|
62
|
+
let t = m.get(e.peerId);
|
|
42
63
|
t && (t.streams = {
|
|
43
64
|
...t.streams,
|
|
44
65
|
[n.name]: null
|
|
45
|
-
},
|
|
46
|
-
},
|
|
47
|
-
},
|
|
48
|
-
let t =
|
|
49
|
-
t && (t.pc.close(),
|
|
50
|
-
},
|
|
51
|
-
if (
|
|
52
|
-
let n =
|
|
66
|
+
}, C());
|
|
67
|
+
}, C());
|
|
68
|
+
}, j = (e) => {
|
|
69
|
+
let t = p.get(e);
|
|
70
|
+
t && (t.pc.close(), p.delete(e)), m.delete(e), b.delete(e), D(v), T(), C(), E();
|
|
71
|
+
}, M = async (e, { offerer: t }) => {
|
|
72
|
+
if (p.has(e)) return;
|
|
73
|
+
let n = s(g), r = {
|
|
53
74
|
peerId: e,
|
|
54
75
|
pc: n,
|
|
55
76
|
transceivers: [],
|
|
@@ -58,65 +79,65 @@ function l(l) {
|
|
|
58
79
|
makingOffer: !1,
|
|
59
80
|
isOfferer: t
|
|
60
81
|
};
|
|
61
|
-
|
|
82
|
+
p.set(e, r), m.set(e, {
|
|
62
83
|
peerId: e,
|
|
63
84
|
connection: n.connectionState,
|
|
64
|
-
streams:
|
|
65
|
-
}), t && (r.transceivers =
|
|
66
|
-
|
|
85
|
+
streams: m.get(e)?.streams ?? S()
|
|
86
|
+
}), t && (r.transceivers = d.map((e) => n.addTransceiver(e.kind, { direction: "sendrecv" })), await o(r, d, h)), n.onicecandidate = (t) => {
|
|
87
|
+
f({
|
|
67
88
|
type: "ice",
|
|
68
89
|
to: e,
|
|
69
90
|
candidate: t.candidate?.candidate ?? null,
|
|
70
91
|
sdpMid: t.candidate?.sdpMid ?? null,
|
|
71
92
|
sdpMLineIndex: t.candidate?.sdpMLineIndex ?? null
|
|
72
93
|
});
|
|
73
|
-
}, n.ontrack = (e) =>
|
|
74
|
-
let t =
|
|
94
|
+
}, n.ontrack = (e) => A(r, e), n.onconnectionstatechange = () => {
|
|
95
|
+
let t = m.get(e);
|
|
75
96
|
if (t && (t.connection = n.connectionState), n.connectionState === "failed" || n.connectionState === "closed") {
|
|
76
|
-
|
|
97
|
+
j(e);
|
|
77
98
|
return;
|
|
78
99
|
}
|
|
79
|
-
|
|
100
|
+
C(), E();
|
|
80
101
|
}, n.onnegotiationneeded = () => {
|
|
81
|
-
r.isOfferer &&
|
|
82
|
-
},
|
|
83
|
-
},
|
|
84
|
-
|
|
85
|
-
let n =
|
|
102
|
+
r.isOfferer && O(r);
|
|
103
|
+
}, T(), C(), E(), t && await O(r);
|
|
104
|
+
}, N = async (e, t) => {
|
|
105
|
+
p.has(e) || await M(e, { offerer: !1 });
|
|
106
|
+
let n = p.get(e);
|
|
86
107
|
if (!n) return;
|
|
87
108
|
await n.pc.setRemoteDescription({
|
|
88
109
|
type: "offer",
|
|
89
110
|
sdp: t
|
|
90
|
-
}), n.remoteReady = !0, await
|
|
111
|
+
}), n.remoteReady = !0, await c(n), n.isOfferer || (a(n), await o(n, d, h));
|
|
91
112
|
let r = await n.pc.createAnswer();
|
|
92
113
|
if (!r.sdp) {
|
|
93
|
-
|
|
114
|
+
u.onNotice?.("empty_local_answer_sdp");
|
|
94
115
|
return;
|
|
95
116
|
}
|
|
96
|
-
await
|
|
117
|
+
await _(n.pc, r), f({
|
|
97
118
|
type: "answer",
|
|
98
119
|
to: e,
|
|
99
120
|
sdp: n.pc.localDescription?.sdp ?? r.sdp
|
|
100
|
-
}), await
|
|
121
|
+
}), await D(v);
|
|
101
122
|
};
|
|
102
|
-
return
|
|
103
|
-
addPeer:
|
|
104
|
-
removePeer:
|
|
123
|
+
return u.onState?.("connecting", "signaling"), {
|
|
124
|
+
addPeer: M,
|
|
125
|
+
removePeer: j,
|
|
105
126
|
accept: async (e) => {
|
|
106
127
|
if (e.type === "offer") {
|
|
107
|
-
await
|
|
128
|
+
await N(e.from, e.sdp);
|
|
108
129
|
return;
|
|
109
130
|
}
|
|
110
|
-
let t =
|
|
131
|
+
let t = p.get(e.from);
|
|
111
132
|
if (t) {
|
|
112
133
|
if (e.type === "answer") {
|
|
113
134
|
await t.pc.setRemoteDescription({
|
|
114
135
|
type: "answer",
|
|
115
136
|
sdp: e.sdp
|
|
116
|
-
}), t.remoteReady = !0, await
|
|
137
|
+
}), t.remoteReady = !0, await c(t), await D(v);
|
|
117
138
|
return;
|
|
118
139
|
}
|
|
119
|
-
e.candidate !== null && await
|
|
140
|
+
e.candidate !== null && await i(t, {
|
|
120
141
|
candidate: e.candidate,
|
|
121
142
|
sdpMid: e.sdpMid ?? void 0,
|
|
122
143
|
sdpMLineIndex: e.sdpMLineIndex ?? void 0
|
|
@@ -124,32 +145,33 @@ function l(l) {
|
|
|
124
145
|
}
|
|
125
146
|
},
|
|
126
147
|
setLocalTrack: async (e, t) => {
|
|
127
|
-
|
|
128
|
-
let n =
|
|
148
|
+
h.set(e, t);
|
|
149
|
+
let n = d.findIndex((t) => t.name === e);
|
|
129
150
|
if (!(n < 0)) {
|
|
130
|
-
for (let e of
|
|
151
|
+
for (let e of p.values()) {
|
|
131
152
|
let r = e.transceivers[n];
|
|
132
153
|
if (r) try {
|
|
133
154
|
await r.sender.replaceTrack(t);
|
|
134
155
|
} catch {}
|
|
135
156
|
}
|
|
136
|
-
await
|
|
157
|
+
await D(v);
|
|
137
158
|
}
|
|
138
159
|
},
|
|
139
|
-
applyQuality:
|
|
160
|
+
applyQuality: D,
|
|
140
161
|
setIceServers: (e) => {
|
|
141
|
-
|
|
162
|
+
g = e;
|
|
142
163
|
},
|
|
143
164
|
stop: () => {
|
|
144
|
-
for (let e of
|
|
145
|
-
|
|
165
|
+
for (let e of p.values()) e.pc.close();
|
|
166
|
+
p.clear(), m.clear(), b.clear(), y = !0, T(), C(), u.onState?.("closed");
|
|
146
167
|
},
|
|
168
|
+
getConnection: (e) => p.get(e)?.pc ?? null,
|
|
147
169
|
get peers() {
|
|
148
|
-
return [...
|
|
170
|
+
return [...m.values()].map((e) => ({ ...e }));
|
|
149
171
|
}
|
|
150
172
|
};
|
|
151
173
|
}
|
|
152
174
|
//#endregion
|
|
153
|
-
export {
|
|
175
|
+
export { u as createPeerMesh };
|
|
154
176
|
|
|
155
177
|
//# sourceMappingURL=peer-mesh.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"peer-mesh.js","names":[],"sources":["../../src/webrtc/peer-mesh.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines — the mesh is one object's lifetime: a link's\n * transceivers, the offer it owes, the tracks published on it and the share of\n * the uplink it costs all change together, and every method here reads or writes\n * the same two maps. The pieces that *could* stand alone already do —\n * `peer-link.ts` for the per-connection mechanics and `mesh-quality.ts` for the\n * division — and what is left is the coordination between them.\n */\nimport { applyQualityToLink, resolveDegradation, scaleForRoom } from \"./mesh-quality\";\nimport {\n acceptCandidate,\n adoptTransceivers,\n attachLocalTracks,\n createPeerConnection,\n drainCandidates,\n slotOf,\n type PeerLink,\n} from \"./peer-link\";\nimport type { MeshMessage, MeshPeer, MeshQuality, PeerMeshOptions } from \"./mesh-types\";\n\n/** A running mesh. */\nexport interface PeerMesh {\n /**\n * Open a link to a peer.\n *\n * `offerer` decides which side sends the offer, and exactly one side of each\n * link must say `true`. Drive it from the arrival order your server already\n * imposes — the peer that joined **later** offers, the one already in the\n * room answers. That avoids glare by construction, without the rollback\n * dance of perfect negotiation and without depending on a browser\n * implementing argument-less `setLocalDescription`.\n */\n addPeer: (peerId: string, options: { offerer: boolean }) => Promise<void>;\n /** Close one link and give its share of the uplink back to the others. */\n removePeer: (peerId: string) => void;\n /** Feed the mesh an `offer`, `answer` or `ice` message from `from`. */\n accept: (message: MeshMessage & { from: string }) => Promise<void>;\n /**\n * Publish or retract one slot across every live link.\n *\n * `null` retracts: the sender keeps its negotiated transceiver and stops\n * producing, which is what mutes a microphone without disturbing the other\n * slots or the connection. Nothing renegotiates — the m-lines were allocated\n * before any track existed, which is what makes a mesh survive toggling: a\n * renegotiation per toggle is N-1 simultaneous offer/answer rounds every time\n * somebody unmutes.\n */\n setLocalTrack: (slot: string, track: MediaStreamTrack | null) => Promise<void>;\n /** Apply encoder limits, divided by the current size of the room. */\n applyQuality: (quality: MeshQuality) => Promise<void>;\n /** Replace the ICE servers used for links opened from now on. */\n setIceServers: (servers: RTCIceServer[]) => void;\n /** Close every link and report `closed`. */\n stop: () => void;\n /** The peers as the mesh currently sees them. */\n readonly peers: MeshPeer[];\n}\n\n/**\n * One `RTCPeerConnection` per peer, with the slots negotiated up front.\n *\n * The signalling protocol stays yours: this produces and accepts three message\n * shapes (`offer`, `answer`, `ice`) and leaves delivery, room membership and\n * identity to your server. What it owns is the part that is the same in every\n * mesh and wrong in most of them — see {@link PeerMesh} for the traps each\n * method exists to avoid.\n *\n * @param options - See {@link PeerMeshOptions}.\n * @returns The mesh.\n *\n * @example\n * const mesh = createPeerMesh({\n * slots: [\n * { name: \"mic\", kind: \"audio\" },\n * { name: \"cam\", kind: \"video\" },\n * ],\n * send: (message) => socket.send(message),\n * onPeers: setPeers,\n * });\n * socket.onMessage = (message) => void mesh.accept(message);\n * await mesh.addPeer(\"peer-2\", { offerer: true });\n * await mesh.setLocalTrack(\"mic\", micTrack);\n */\nexport function createPeerMesh(options: PeerMeshOptions): PeerMesh {\n const { slots, send } = options;\n const links = new Map<string, PeerLink>();\n const peers = new Map<string, MeshPeer>();\n const localTracks = new Map<string, MediaStreamTrack | null>();\n let iceServers = options.iceServers ?? [];\n const describe =\n options.setLocalDescription ??\n ((connection: RTCPeerConnection, description: RTCSessionDescriptionInit) =>\n connection.setLocalDescription(description));\n let quality: MeshQuality = options.quality ?? {};\n let stopped = false;\n\n const emptyStreams = (): Record<string, MediaStream | null> =>\n Object.fromEntries(slots.map((slot) => [slot.name, null]));\n\n const emitPeers = (): void => {\n options.onPeers?.([...peers.values()].map((peer) => ({ ...peer })));\n };\n\n /**\n * Collapse the per-link states into the one badge a call shows.\n *\n * No links at all is `connected`, not a failure: it means nobody else is in\n * the room yet, which is a legitimate state a call sits in for as long as the\n * first person is early.\n */\n const refreshState = (): void => {\n if (stopped) return;\n const states = [...links.values()].map((link) => link.pc.connectionState);\n if (states.some((state) => state === \"connected\")) options.onState?.(\"connected\");\n else if (states.length > 0) options.onState?.(\"connecting\", \"negotiating\");\n else options.onState?.(\"connected\", \"alone\");\n };\n\n const applyQuality = async (next: MeshQuality): Promise<void> => {\n quality = next;\n const effective = scaleForRoom(next, links.size);\n const degradation = resolveDegradation(next, effective);\n for (const link of links.values()) {\n await applyQualityToLink(link.transceivers, slots, effective, degradation);\n }\n };\n\n const makeOffer = async (link: PeerLink): Promise<void> => {\n if (link.makingOffer) return;\n link.makingOffer = true;\n try {\n const offer = await link.pc.createOffer();\n if (!offer.sdp) {\n options.onNotice?.(\"empty_local_offer_sdp\");\n return;\n }\n await describe(link.pc, offer);\n send({\n type: \"offer\",\n to: link.peerId,\n sdp: link.pc.localDescription?.sdp ?? offer.sdp,\n });\n } catch {\n options.onNotice?.(\"offer_failed\");\n } finally {\n link.makingOffer = false;\n }\n };\n\n /**\n * Wrap an inbound track in the stream the caller renders.\n *\n * The event's own stream is preferred: it is the one the sending side\n * grouped the track into, so a caller that reads `stream.id` sees what the\n * peer published rather than a wrapper invented here. Browsers only fill it\n * when the offer named a stream, hence the fallback — and the guard after\n * that is for a runtime with no `MediaStream` at all, where routing still\n * has to not throw.\n */\n const streamFor = (event: RTCTrackEvent): MediaStream | null => {\n const provided = event.streams?.[0];\n if (provided) return provided;\n return typeof MediaStream === \"undefined\" ? null : new MediaStream([event.track]);\n };\n\n const attachRemoteTrack = (link: PeerLink, event: RTCTrackEvent): void => {\n const slot = slotOf(event.transceiver, link, slots);\n const peer = peers.get(link.peerId);\n if (!slot || !peer) return;\n\n peer.streams = { ...peer.streams, [slot.name]: streamFor(event) };\n event.track.onended = (): void => {\n const current = peers.get(link.peerId);\n if (!current) return;\n current.streams = { ...current.streams, [slot.name]: null };\n emitPeers();\n };\n emitPeers();\n };\n\n const removePeer = (peerId: string): void => {\n const link = links.get(peerId);\n if (link) {\n link.pc.close();\n links.delete(peerId);\n }\n peers.delete(peerId);\n void applyQuality(quality);\n emitPeers();\n refreshState();\n };\n\n const addPeer = async (peerId: string, { offerer }: { offerer: boolean }): Promise<void> => {\n if (links.has(peerId)) return;\n\n const pc = createPeerConnection(iceServers);\n const link: PeerLink = {\n peerId,\n pc,\n transceivers: [],\n pendingCandidates: [],\n remoteReady: false,\n makingOffer: false,\n isOfferer: offerer,\n };\n links.set(peerId, link);\n peers.set(peerId, {\n peerId,\n connection: pc.connectionState,\n streams: peers.get(peerId)?.streams ?? emptyStreams(),\n });\n\n if (offerer) {\n link.transceivers = slots.map((slot) =>\n pc.addTransceiver(slot.kind, { direction: \"sendrecv\" }),\n );\n await attachLocalTracks(link, slots, localTracks);\n }\n\n pc.onicecandidate = (event): void => {\n send({\n type: \"ice\",\n to: peerId,\n candidate: event.candidate?.candidate ?? null,\n sdpMid: event.candidate?.sdpMid ?? null,\n sdpMLineIndex: event.candidate?.sdpMLineIndex ?? null,\n });\n };\n\n pc.ontrack = (event): void => attachRemoteTrack(link, event);\n\n pc.onconnectionstatechange = (): void => {\n const peer = peers.get(peerId);\n if (peer) peer.connection = pc.connectionState;\n if (pc.connectionState === \"failed\" || pc.connectionState === \"closed\") {\n removePeer(peerId);\n return;\n }\n emitPeers();\n refreshState();\n };\n\n pc.onnegotiationneeded = (): void => {\n if (link.isOfferer) void makeOffer(link);\n };\n\n emitPeers();\n refreshState();\n if (offerer) await makeOffer(link);\n };\n\n const acceptOffer = async (from: string, sdp: string): Promise<void> => {\n if (!links.has(from)) await addPeer(from, { offerer: false });\n const link = links.get(from);\n if (!link) return;\n\n await link.pc.setRemoteDescription({ type: \"offer\", sdp });\n link.remoteReady = true;\n await drainCandidates(link);\n\n if (!link.isOfferer) {\n adoptTransceivers(link);\n await attachLocalTracks(link, slots, localTracks);\n }\n\n const answer = await link.pc.createAnswer();\n if (!answer.sdp) {\n options.onNotice?.(\"empty_local_answer_sdp\");\n return;\n }\n await describe(link.pc, answer);\n send({ type: \"answer\", to: from, sdp: link.pc.localDescription?.sdp ?? answer.sdp });\n await applyQuality(quality);\n };\n\n const accept = async (message: MeshMessage & { from: string }): Promise<void> => {\n if (message.type === \"offer\") {\n await acceptOffer(message.from, message.sdp);\n return;\n }\n\n const link = links.get(message.from);\n if (!link) return;\n\n if (message.type === \"answer\") {\n await link.pc.setRemoteDescription({ type: \"answer\", sdp: message.sdp });\n link.remoteReady = true;\n await drainCandidates(link);\n await applyQuality(quality);\n return;\n }\n\n if (message.candidate === null) return;\n await acceptCandidate(link, {\n candidate: message.candidate,\n sdpMid: message.sdpMid ?? undefined,\n sdpMLineIndex: message.sdpMLineIndex ?? undefined,\n });\n };\n\n const setLocalTrack = async (slot: string, track: MediaStreamTrack | null): Promise<void> => {\n localTracks.set(slot, track);\n const index = slots.findIndex((entry) => entry.name === slot);\n if (index < 0) return;\n\n for (const link of links.values()) {\n const transceiver = link.transceivers[index];\n if (!transceiver) continue;\n try {\n await transceiver.sender.replaceTrack(track);\n } catch {\n /* the link is tearing down; its state handler will remove it */\n }\n }\n await applyQuality(quality);\n };\n\n const stop = (): void => {\n for (const link of links.values()) link.pc.close();\n links.clear();\n peers.clear();\n stopped = true;\n emitPeers();\n options.onState?.(\"closed\");\n };\n\n options.onState?.(\"connecting\", \"signaling\");\n\n return {\n addPeer,\n removePeer,\n accept,\n setLocalTrack,\n applyQuality,\n setIceServers: (servers): void => {\n iceServers = servers;\n },\n stop,\n get peers(): MeshPeer[] {\n return [...peers.values()].map((peer) => ({ ...peer }));\n },\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"peer-mesh.js","names":[],"sources":["../../src/webrtc/peer-mesh.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines — the mesh is one object's lifetime: a link's\n * transceivers, the offer it owes, the tracks published on it and the share of\n * the uplink it costs all change together, and every method here reads or writes\n * the same two maps. The pieces that *could* stand alone already do —\n * `peer-link.ts` for the per-connection mechanics and `mesh-quality.ts` for the\n * division — and what is left is the coordination between them.\n */\nimport { applyQualityToLink, resolveDegradation, scaleForRoom } from \"./mesh-quality\";\nimport {\n acceptCandidate,\n adoptTransceivers,\n attachLocalTracks,\n createPeerConnection,\n drainCandidates,\n slotOf,\n type PeerLink,\n} from \"./peer-link\";\nimport { createLinkStatsSampler, type LinkStatsSampler } from \"./link-stats\";\nimport type {\n MeshMessage,\n MeshPeer,\n MeshQuality,\n MeshStatsOptions,\n PeerMeshOptions,\n} from \"./mesh-types\";\n\n/** A running mesh. */\nexport interface PeerMesh {\n /**\n * Open a link to a peer.\n *\n * `offerer` decides which side sends the offer, and exactly one side of each\n * link must say `true`. Drive it from the arrival order your server already\n * imposes — the peer that joined **later** offers, the one already in the\n * room answers. That avoids glare by construction, without the rollback\n * dance of perfect negotiation and without depending on a browser\n * implementing argument-less `setLocalDescription`.\n */\n addPeer: (peerId: string, options: { offerer: boolean }) => Promise<void>;\n /** Close one link and give its share of the uplink back to the others. */\n removePeer: (peerId: string) => void;\n /** Feed the mesh an `offer`, `answer` or `ice` message from `from`. */\n accept: (message: MeshMessage & { from: string }) => Promise<void>;\n /**\n * Publish or retract one slot across every live link.\n *\n * `null` retracts: the sender keeps its negotiated transceiver and stops\n * producing, which is what mutes a microphone without disturbing the other\n * slots or the connection. Nothing renegotiates — the m-lines were allocated\n * before any track existed, which is what makes a mesh survive toggling: a\n * renegotiation per toggle is N-1 simultaneous offer/answer rounds every time\n * somebody unmutes.\n */\n setLocalTrack: (slot: string, track: MediaStreamTrack | null) => Promise<void>;\n /** Apply encoder limits, divided by the current size of the room. */\n applyQuality: (quality: MeshQuality) => Promise<void>;\n /** Replace the ICE servers used for links opened from now on. */\n setIceServers: (servers: RTCIceServer[]) => void;\n /** Close every link and report `closed`. */\n stop: () => void;\n /** The peers as the mesh currently sees them. */\n readonly peers: MeshPeer[];\n /**\n * The connection carrying one peer's link, or `null`.\n *\n * The escape hatch for everything this mesh does not model. `getStats()`,\n * `getSenders()`, an `RTCDataChannel`, an encoding tweak the `quality`\n * shape has no field for — all of it lives on the connection, and a mesh\n * that keeps it private turns adopting the mesh into losing a feature.\n *\n * {@link MeshPeer.connection} is the *state*, which is what a view needs;\n * this is the object, which is what a measurement needs. For the common\n * case of measuring, {@link PeerMeshOptions.stats} spares you the loop —\n * including the one-sampler-per-connection rule that a hand-rolled version\n * gets wrong.\n *\n * @param peerId - The id the peer was added under.\n * @returns The connection, or `null` when there is no link for that id.\n */\n getConnection: (peerId: string) => RTCPeerConnection | null;\n}\n\n/**\n * One `RTCPeerConnection` per peer, with the slots negotiated up front.\n *\n * The signalling protocol stays yours: this produces and accepts three message\n * shapes (`offer`, `answer`, `ice`) and leaves delivery, room membership and\n * identity to your server. What it owns is the part that is the same in every\n * mesh and wrong in most of them — see {@link PeerMesh} for the traps each\n * method exists to avoid.\n *\n * @param options - See {@link PeerMeshOptions}.\n * @returns The mesh.\n *\n * @example\n * const mesh = createPeerMesh({\n * slots: [\n * { name: \"mic\", kind: \"audio\" },\n * { name: \"cam\", kind: \"video\" },\n * ],\n * send: (message) => socket.send(message),\n * onPeers: setPeers,\n * });\n * socket.onMessage = (message) => void mesh.accept(message);\n * await mesh.addPeer(\"peer-2\", { offerer: true });\n * await mesh.setLocalTrack(\"mic\", micTrack);\n */\nexport function createPeerMesh(options: PeerMeshOptions): PeerMesh {\n const { slots, send } = options;\n const links = new Map<string, PeerLink>();\n const peers = new Map<string, MeshPeer>();\n const localTracks = new Map<string, MediaStreamTrack | null>();\n let iceServers = options.iceServers ?? [];\n const describe =\n options.setLocalDescription ??\n ((connection: RTCPeerConnection, description: RTCSessionDescriptionInit) =>\n connection.setLocalDescription(description));\n let quality: MeshQuality = options.quality ?? {};\n let stopped = false;\n const samplers = new Map<string, LinkStatsSampler>();\n let statsTimer: ReturnType<typeof setInterval> | null = null;\n\n const emptyStreams = (): Record<string, MediaStream | null> =>\n Object.fromEntries(slots.map((slot) => [slot.name, null]));\n\n const emitPeers = (): void => {\n options.onPeers?.([...peers.values()].map((peer) => ({ ...peer })));\n };\n\n /**\n * Sample every connected link once.\n *\n * Only `connected` links are asked. A link still gathering candidates has\n * no traffic to measure, and asking anyway spends a `getStats()` round trip\n * to learn that — per link, on every tick.\n *\n * `settings` arrives as an argument rather than being read from `options`\n * here: the timer is only ever armed past the check that they exist, so\n * reading them again would add a branch that nothing can reach.\n *\n * @param settings - The measuring options, already known to be present.\n */\n const sampleLinks = async (settings: MeshStatsOptions): Promise<void> => {\n if (stopped) return;\n let changed = false;\n\n for (const link of [...links.values()]) {\n if (link.pc.connectionState !== \"connected\") continue;\n let sampler = samplers.get(link.peerId);\n if (sampler === undefined) {\n sampler = createLinkStatsSampler(\n settings.kind === undefined ? {} : { kind: settings.kind },\n );\n samplers.set(link.peerId, sampler);\n }\n const stats = await sampler.sample(link.pc);\n const peer = peers.get(link.peerId);\n /*\n * Belt and braces, and the belt is the state check above: a peer\n * removed from inside `onStats` has its connection closed first, so\n * the next iteration skips it there rather than here. This catches\n * the entry disappearing any other way across the await.\n */\n if (peer === undefined) continue;\n peer.stats = stats;\n changed = true;\n settings.onStats?.(link.peerId, stats);\n }\n\n if (changed) emitPeers();\n };\n\n /**\n * Keep the timer alive exactly while there is something to measure.\n *\n * One timer for the whole mesh, not one per peer — and none at all in an\n * empty room, which is where a call sits for as long as the first person is\n * early.\n */\n const syncStatsTimer = (): void => {\n const settings = options.stats;\n if (settings === undefined) return;\n const wanted = !stopped && links.size > 0;\n if (wanted && statsTimer === null) {\n statsTimer = setInterval(() => void sampleLinks(settings), settings.intervalMs ?? 2000);\n return;\n }\n if (!wanted && statsTimer !== null) {\n clearInterval(statsTimer);\n statsTimer = null;\n }\n };\n\n /**\n * Collapse the per-link states into the one badge a call shows.\n *\n * No links at all is `connected`, not a failure: it means nobody else is in\n * the room yet, which is a legitimate state a call sits in for as long as the\n * first person is early.\n */\n const refreshState = (): void => {\n if (stopped) return;\n const states = [...links.values()].map((link) => link.pc.connectionState);\n if (states.some((state) => state === \"connected\")) options.onState?.(\"connected\");\n else if (states.length > 0) options.onState?.(\"connecting\", \"negotiating\");\n else options.onState?.(\"connected\", \"alone\");\n };\n\n const applyQuality = async (next: MeshQuality): Promise<void> => {\n quality = next;\n const effective = scaleForRoom(next, links.size);\n const degradation = resolveDegradation(next, effective);\n for (const link of links.values()) {\n await applyQualityToLink(link.transceivers, slots, effective, degradation);\n }\n };\n\n const makeOffer = async (link: PeerLink): Promise<void> => {\n if (link.makingOffer) return;\n link.makingOffer = true;\n try {\n const offer = await link.pc.createOffer();\n if (!offer.sdp) {\n options.onNotice?.(\"empty_local_offer_sdp\");\n return;\n }\n await describe(link.pc, offer);\n send({\n type: \"offer\",\n to: link.peerId,\n sdp: link.pc.localDescription?.sdp ?? offer.sdp,\n });\n } catch {\n options.onNotice?.(\"offer_failed\");\n } finally {\n link.makingOffer = false;\n }\n };\n\n /**\n * Wrap an inbound track in the stream the caller renders.\n *\n * The event's own stream is preferred: it is the one the sending side\n * grouped the track into, so a caller that reads `stream.id` sees what the\n * peer published rather than a wrapper invented here. Browsers only fill it\n * when the offer named a stream, hence the fallback — and the guard after\n * that is for a runtime with no `MediaStream` at all, where routing still\n * has to not throw.\n */\n const streamFor = (event: RTCTrackEvent): MediaStream | null => {\n const provided = event.streams?.[0];\n if (provided) return provided;\n return typeof MediaStream === \"undefined\" ? null : new MediaStream([event.track]);\n };\n\n const attachRemoteTrack = (link: PeerLink, event: RTCTrackEvent): void => {\n const slot = slotOf(event.transceiver, link, slots);\n const peer = peers.get(link.peerId);\n if (!slot || !peer) return;\n\n peer.streams = { ...peer.streams, [slot.name]: streamFor(event) };\n event.track.onended = (): void => {\n const current = peers.get(link.peerId);\n if (!current) return;\n current.streams = { ...current.streams, [slot.name]: null };\n emitPeers();\n };\n emitPeers();\n };\n\n const removePeer = (peerId: string): void => {\n const link = links.get(peerId);\n if (link) {\n link.pc.close();\n links.delete(peerId);\n }\n peers.delete(peerId);\n samplers.delete(peerId);\n void applyQuality(quality);\n syncStatsTimer();\n emitPeers();\n refreshState();\n };\n\n const addPeer = async (peerId: string, { offerer }: { offerer: boolean }): Promise<void> => {\n if (links.has(peerId)) return;\n\n const pc = createPeerConnection(iceServers);\n const link: PeerLink = {\n peerId,\n pc,\n transceivers: [],\n pendingCandidates: [],\n remoteReady: false,\n makingOffer: false,\n isOfferer: offerer,\n };\n links.set(peerId, link);\n peers.set(peerId, {\n peerId,\n connection: pc.connectionState,\n streams: peers.get(peerId)?.streams ?? emptyStreams(),\n });\n\n if (offerer) {\n link.transceivers = slots.map((slot) =>\n pc.addTransceiver(slot.kind, { direction: \"sendrecv\" }),\n );\n await attachLocalTracks(link, slots, localTracks);\n }\n\n pc.onicecandidate = (event): void => {\n send({\n type: \"ice\",\n to: peerId,\n candidate: event.candidate?.candidate ?? null,\n sdpMid: event.candidate?.sdpMid ?? null,\n sdpMLineIndex: event.candidate?.sdpMLineIndex ?? null,\n });\n };\n\n pc.ontrack = (event): void => attachRemoteTrack(link, event);\n\n pc.onconnectionstatechange = (): void => {\n const peer = peers.get(peerId);\n if (peer) peer.connection = pc.connectionState;\n if (pc.connectionState === \"failed\" || pc.connectionState === \"closed\") {\n removePeer(peerId);\n return;\n }\n emitPeers();\n refreshState();\n };\n\n pc.onnegotiationneeded = (): void => {\n if (link.isOfferer) void makeOffer(link);\n };\n\n syncStatsTimer();\n emitPeers();\n refreshState();\n if (offerer) await makeOffer(link);\n };\n\n const acceptOffer = async (from: string, sdp: string): Promise<void> => {\n if (!links.has(from)) await addPeer(from, { offerer: false });\n const link = links.get(from);\n if (!link) return;\n\n await link.pc.setRemoteDescription({ type: \"offer\", sdp });\n link.remoteReady = true;\n await drainCandidates(link);\n\n if (!link.isOfferer) {\n adoptTransceivers(link);\n await attachLocalTracks(link, slots, localTracks);\n }\n\n const answer = await link.pc.createAnswer();\n if (!answer.sdp) {\n options.onNotice?.(\"empty_local_answer_sdp\");\n return;\n }\n await describe(link.pc, answer);\n send({ type: \"answer\", to: from, sdp: link.pc.localDescription?.sdp ?? answer.sdp });\n await applyQuality(quality);\n };\n\n const accept = async (message: MeshMessage & { from: string }): Promise<void> => {\n if (message.type === \"offer\") {\n await acceptOffer(message.from, message.sdp);\n return;\n }\n\n const link = links.get(message.from);\n if (!link) return;\n\n if (message.type === \"answer\") {\n await link.pc.setRemoteDescription({ type: \"answer\", sdp: message.sdp });\n link.remoteReady = true;\n await drainCandidates(link);\n await applyQuality(quality);\n return;\n }\n\n if (message.candidate === null) return;\n await acceptCandidate(link, {\n candidate: message.candidate,\n sdpMid: message.sdpMid ?? undefined,\n sdpMLineIndex: message.sdpMLineIndex ?? undefined,\n });\n };\n\n const setLocalTrack = async (slot: string, track: MediaStreamTrack | null): Promise<void> => {\n localTracks.set(slot, track);\n const index = slots.findIndex((entry) => entry.name === slot);\n if (index < 0) return;\n\n for (const link of links.values()) {\n const transceiver = link.transceivers[index];\n if (!transceiver) continue;\n try {\n await transceiver.sender.replaceTrack(track);\n } catch {\n /* the link is tearing down; its state handler will remove it */\n }\n }\n await applyQuality(quality);\n };\n\n const stop = (): void => {\n for (const link of links.values()) link.pc.close();\n links.clear();\n peers.clear();\n samplers.clear();\n stopped = true;\n syncStatsTimer();\n emitPeers();\n options.onState?.(\"closed\");\n };\n\n options.onState?.(\"connecting\", \"signaling\");\n\n return {\n addPeer,\n removePeer,\n accept,\n setLocalTrack,\n applyQuality,\n setIceServers: (servers): void => {\n iceServers = servers;\n },\n stop,\n getConnection: (peerId: string): RTCPeerConnection | null => links.get(peerId)?.pc ?? null,\n get peers(): MeshPeer[] {\n return [...peers.values()].map((peer) => ({ ...peer }));\n },\n };\n}\n"],"mappings":"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"}
|