tempest-react-sdk 0.54.0 → 0.56.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/AudioPlayer/AudioPlayer.cjs +1 -1
- package/dist/components/AudioPlayer/AudioPlayer.js +6 -6
- 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/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/VideoPlayer.css +23 -0
- package/dist/styles/media.css +22 -0
- package/dist/styles.css +1 -1
- package/dist/tempest-react-sdk.cjs +1 -1
- package/dist/tempest-react-sdk.d.ts +389 -23
- package/dist/tempest-react-sdk.js +213 -209
- package/dist/webrtc/mesh-quality.cjs +2 -0
- package/dist/webrtc/mesh-quality.cjs.map +1 -0
- package/dist/webrtc/mesh-quality.js +51 -0
- package/dist/webrtc/mesh-quality.js.map +1 -0
- package/dist/webrtc/peer-link.cjs +2 -0
- package/dist/webrtc/peer-link.cjs.map +1 -0
- package/dist/webrtc/peer-link.js +45 -0
- package/dist/webrtc/peer-link.js.map +1 -0
- package/dist/webrtc/peer-mesh.cjs +2 -0
- package/dist/webrtc/peer-mesh.cjs.map +1 -0
- package/dist/webrtc/peer-mesh.js +155 -0
- package/dist/webrtc/peer-mesh.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { setSenderBitrate as e } from "./sender-bitrate.js";
|
|
2
|
+
//#region src/webrtc/mesh-quality.ts
|
|
3
|
+
var t = 6e3, n = 300, r = 900;
|
|
4
|
+
function i(e, r) {
|
|
5
|
+
let i = e.video ?? {};
|
|
6
|
+
if (r <= 1) return e;
|
|
7
|
+
let a = Object.values(i).reduce((e, t) => e + (t ?? 0), 0);
|
|
8
|
+
if (a === 0) return e;
|
|
9
|
+
let o = (e.uplinkBudgetKbps ?? t) / r;
|
|
10
|
+
if (a <= o) return e;
|
|
11
|
+
let s = e.minVideoKbps ?? n, c = o / a, l = {};
|
|
12
|
+
for (let [e, t] of Object.entries(i)) l[e] = t === null ? null : Math.max(s, Math.round(t * c));
|
|
13
|
+
return {
|
|
14
|
+
...e,
|
|
15
|
+
video: l
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function a(e, t) {
|
|
19
|
+
let n = e.degradationPreference;
|
|
20
|
+
if (n !== "maintain-framerate") return n;
|
|
21
|
+
let i = Object.values(t.video ?? {}).filter((e) => e !== null);
|
|
22
|
+
return i.length === 0 ? n : Math.max(...i) >= (e.fluidFloorKbps ?? r) ? "maintain-framerate" : "maintain-resolution";
|
|
23
|
+
}
|
|
24
|
+
function o(e, t, n) {
|
|
25
|
+
let r = e.getParameters();
|
|
26
|
+
if (t !== void 0 && (r.degradationPreference = t), n !== void 0) for (let e of r.encodings ?? []) e.maxFramerate = n;
|
|
27
|
+
return r;
|
|
28
|
+
}
|
|
29
|
+
async function s(t, n, r, i) {
|
|
30
|
+
for (let [a, s] of n.entries()) {
|
|
31
|
+
let n = t[a]?.sender;
|
|
32
|
+
if (!n) continue;
|
|
33
|
+
if (s.kind === "audio") {
|
|
34
|
+
let t = r.audio?.[s.name];
|
|
35
|
+
t !== void 0 && await e(n, t);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
let c = r.video?.[s.name];
|
|
39
|
+
if (c !== void 0 && await e(n, c === null ? null : c * 1e3), i !== void 0 || r.maxFramerate !== void 0) try {
|
|
40
|
+
await n.setParameters(o(n, i, r.maxFramerate));
|
|
41
|
+
} catch {
|
|
42
|
+
try {
|
|
43
|
+
await n.setParameters(o(n, void 0, r.maxFramerate));
|
|
44
|
+
} catch {}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
//#endregion
|
|
49
|
+
export { s as applyQualityToLink, a as resolveDegradation, i as scaleForRoom };
|
|
50
|
+
|
|
51
|
+
//# sourceMappingURL=mesh-quality.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mesh-quality.js","names":[],"sources":["../../src/webrtc/mesh-quality.ts"],"sourcesContent":["import { setSenderBitrate } from \"./sender-bitrate\";\nimport type { MeshQuality, MeshSlot } from \"./mesh-types\";\n\n/** Uplink assumed available when the caller names no budget, in kbps. */\nconst DEFAULT_UPLINK_BUDGET_KBPS = 6000;\n\n/** Floor a video slot keeps after the division, in kbps. */\nconst DEFAULT_MIN_VIDEO_KBPS = 300;\n\n/** Budget below which `maintain-framerate` stops being worth holding, in kbps. */\nconst DEFAULT_FLUID_FLOOR_KBPS = 900;\n\n/**\n * Divide the video caps by the size of the room.\n *\n * A mesh sends one copy of everything per participant, so the uplink is the\n * shared resource and the caps are what compete for it. Nothing is divided while\n * the caller is alone with one peer — that is the case the largest sizes exist\n * for, and dividing a budget that is not being shared would make them\n * unreachable in the only call where they fit.\n *\n * The floor is what keeps the division honest: without it a busy room allocates\n * tens of kbps per stream, and everybody loses the picture instead of the excess\n * giving way.\n *\n * @param quality - The caps as asked for.\n * @param peers - How many links are live.\n * @returns The caps to actually apply. The input is never mutated, so a room\n * that empties out climbs back to what was asked for.\n */\nexport function scaleForRoom(quality: MeshQuality, peers: number): MeshQuality {\n const video = quality.video ?? {};\n if (peers <= 1) return quality;\n\n const asked = Object.values(video).reduce<number>((sum, cap) => sum + (cap ?? 0), 0);\n if (asked === 0) return quality;\n\n const perPeer = (quality.uplinkBudgetKbps ?? DEFAULT_UPLINK_BUDGET_KBPS) / peers;\n if (asked <= perPeer) return quality;\n\n const floor = quality.minVideoKbps ?? DEFAULT_MIN_VIDEO_KBPS;\n const factor = perPeer / asked;\n const scaled: Record<string, number | null> = {};\n for (const [slot, cap] of Object.entries(video)) {\n scaled[slot] = cap === null ? null : Math.max(floor, Math.round(cap * factor));\n }\n return { ...quality, video: scaled };\n}\n\n/**\n * Decide what the encoder gives up, letting physics override the preference.\n *\n * `maintain-framerate` is honoured while there are bits enough for the frames to\n * be worth keeping. Once the room's division has taken the budget below the\n * fluid floor, holding the rate halves what each frame receives and the picture\n * is worse than the lower rate it replaced — so below that line the preference\n * is overridden rather than obeyed.\n *\n * @param quality - What was asked for.\n * @param effective - What the room's share actually allows.\n * @returns The degradation preference to write onto the senders, or `undefined`\n * when the caller expressed no preference.\n */\nexport function resolveDegradation(\n quality: MeshQuality,\n effective: MeshQuality,\n): RTCDegradationPreference | undefined {\n const asked = quality.degradationPreference;\n if (asked !== \"maintain-framerate\") return asked;\n\n const caps = Object.values(effective.video ?? {}).filter((cap): cap is number => cap !== null);\n if (caps.length === 0) return asked;\n\n const budget = Math.max(...caps);\n return budget >= (quality.fluidFloorKbps ?? DEFAULT_FLUID_FLOOR_KBPS)\n ? \"maintain-framerate\"\n : \"maintain-resolution\";\n}\n\n/**\n * Read a video sender's parameters back with the motion settings written on.\n *\n * Read fresh on every call because `setParameters` only accepts the object the\n * **same** sender's `getParameters` returned, so a rejected attempt cannot be\n * retried with the object that was rejected.\n *\n * @param sender - The video sender to read from.\n * @param degradation - What to give up first, or `undefined` to leave it alone.\n * @param fps - Frame ceiling, or `undefined` to leave it alone.\n * @returns Parameters ready to be written back to `sender`.\n */\nfunction videoParameters(\n sender: RTCRtpSender,\n degradation: RTCDegradationPreference | undefined,\n fps: number | undefined,\n): RTCRtpSendParameters {\n const params = sender.getParameters();\n if (degradation !== undefined) params.degradationPreference = degradation;\n if (fps !== undefined) {\n for (const encoding of params.encodings ?? []) encoding.maxFramerate = fps;\n }\n return params;\n}\n\n/**\n * Write the quality settings onto one link's senders.\n *\n * `degradationPreference` goes only on video: it describes trading resolution\n * against frame rate, which an audio sender has no analogue for, and some\n * browsers reject it outright there.\n *\n * The retry without `degradationPreference` is for Firefox, which rejects the\n * member entirely — sending both together would lose the frame-rate cap to an\n * objection about something else.\n *\n * @param transceivers - The link's transceivers, in slot order.\n * @param slots - The slot list those transceivers were allocated from.\n * @param effective - Caps after the room's division.\n * @param degradation - Already resolved against the fluid floor.\n */\nexport async function applyQualityToLink(\n transceivers: readonly RTCRtpTransceiver[],\n slots: readonly MeshSlot[],\n effective: MeshQuality,\n degradation: RTCDegradationPreference | undefined,\n): Promise<void> {\n for (const [index, slot] of slots.entries()) {\n const sender = transceivers[index]?.sender;\n if (!sender) continue;\n\n if (slot.kind === \"audio\") {\n const bps = effective.audio?.[slot.name];\n if (bps !== undefined) await setSenderBitrate(sender, bps);\n continue;\n }\n\n const kbps = effective.video?.[slot.name];\n if (kbps !== undefined) await setSenderBitrate(sender, kbps === null ? null : kbps * 1000);\n\n if (degradation === undefined && effective.maxFramerate === undefined) continue;\n try {\n await sender.setParameters(\n videoParameters(sender, degradation, effective.maxFramerate),\n );\n } catch {\n try {\n await sender.setParameters(\n videoParameters(sender, undefined, effective.maxFramerate),\n );\n } catch {\n /* the sender refused both; the bitrate cap above still applies */\n }\n }\n }\n}\n"],"mappings":";;AAIA,IAAM,IAA6B,KAG7B,IAAyB,KAGzB,IAA2B;AAoBjC,SAAgB,EAAa,GAAsB,GAA4B;CAC3E,IAAM,IAAQ,EAAQ,SAAS,CAAC;CAChC,IAAI,KAAS,GAAG,OAAO;CAEvB,IAAM,IAAQ,OAAO,OAAO,CAAK,CAAC,CAAC,QAAgB,GAAK,MAAQ,KAAO,KAAO,IAAI,CAAC;CACnF,IAAI,MAAU,GAAG,OAAO;CAExB,IAAM,KAAW,EAAQ,oBAAoB,KAA8B;CAC3E,IAAI,KAAS,GAAS,OAAO;CAE7B,IAAM,IAAQ,EAAQ,gBAAgB,GAChC,IAAS,IAAU,GACnB,IAAwC,CAAC;CAC/C,KAAK,IAAM,CAAC,GAAM,MAAQ,OAAO,QAAQ,CAAK,GAC1C,EAAO,KAAQ,MAAQ,OAAO,OAAO,KAAK,IAAI,GAAO,KAAK,MAAM,IAAM,CAAM,CAAC;CAEjF,OAAO;EAAE,GAAG;EAAS,OAAO;CAAO;AACvC;AAgBA,SAAgB,EACZ,GACA,GACoC;CACpC,IAAM,IAAQ,EAAQ;CACtB,IAAI,MAAU,sBAAsB,OAAO;CAE3C,IAAM,IAAO,OAAO,OAAO,EAAU,SAAS,CAAC,CAAC,CAAC,CAAC,QAAQ,MAAuB,MAAQ,IAAI;CAI7F,OAHI,EAAK,WAAW,IAAU,IAEf,KAAK,IAAI,GAAG,CACpB,MAAW,EAAQ,kBAAkB,KACtC,uBACA;AACV;AAcA,SAAS,EACL,GACA,GACA,GACoB;CACpB,IAAM,IAAS,EAAO,cAAc;CAEpC,IADI,MAAgB,KAAA,MAAW,EAAO,wBAAwB,IAC1D,MAAQ,KAAA,GACR,KAAK,IAAM,KAAY,EAAO,aAAa,CAAC,GAAG,EAAS,eAAe;CAE3E,OAAO;AACX;AAkBA,eAAsB,EAClB,GACA,GACA,GACA,GACa;CACb,KAAK,IAAM,CAAC,GAAO,MAAS,EAAM,QAAQ,GAAG;EACzC,IAAM,IAAS,EAAa,EAAM,EAAE;EACpC,IAAI,CAAC,GAAQ;EAEb,IAAI,EAAK,SAAS,SAAS;GACvB,IAAM,IAAM,EAAU,QAAQ,EAAK;GACnC,AAAI,MAAQ,KAAA,KAAW,MAAM,EAAiB,GAAQ,CAAG;GACzD;EACJ;EAEA,IAAM,IAAO,EAAU,QAAQ,EAAK;EACpC,IAAI,MAAS,KAAA,KAAW,MAAM,EAAiB,GAAQ,MAAS,OAAO,OAAO,IAAO,GAAI,GAErF,MAAgB,KAAA,KAAa,EAAU,iBAAiB,KAAA,GAC5D,IAAI;GACA,MAAM,EAAO,cACT,EAAgB,GAAQ,GAAa,EAAU,YAAY,CAC/D;EACJ,QAAQ;GACJ,IAAI;IACA,MAAM,EAAO,cACT,EAAgB,GAAQ,KAAA,GAAW,EAAU,YAAY,CAC7D;GACJ,QAAQ,CAER;EACJ;CACJ;AACJ"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
function e(e){return new RTCPeerConnection({iceServers:e,bundlePolicy:`max-bundle`,rtcpMuxPolicy:`require`})}function t(e,t,n){let r=Number(e.mid);if(e.mid!==null&&Number.isInteger(r)&&n[r])return n[r];let i=t.transceivers.indexOf(e);return i>=0?n[i]??null:null}async function n(e){let t=e.pendingCandidates.splice(0,e.pendingCandidates.length);for(let n of t)try{await e.pc.addIceCandidate(n)}catch{}}async function r(e,t){if(!e.remoteReady){e.pendingCandidates.push(t);return}try{await e.pc.addIceCandidate(t)}catch{}}async function i(e,t,n){for(let[r,i]of t.entries()){let t=e.transceivers[r],a=n.get(i.name)??null;if(!(!t||!a))try{await t.sender.replaceTrack(a)}catch{}}}function a(e){e.transceivers=e.pc.getTransceivers().filter(e=>e.mid!==null).sort((e,t)=>Number(e.mid)-Number(t.mid));for(let t of e.transceivers)t.direction!==`sendrecv`&&(t.direction=`sendrecv`)}exports.acceptCandidate=r,exports.adoptTransceivers=a,exports.attachLocalTracks=i,exports.createPeerConnection=e,exports.drainCandidates=n,exports.slotOf=t;
|
|
2
|
+
//# sourceMappingURL=peer-link.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"peer-link.cjs","names":[],"sources":["../../src/webrtc/peer-link.ts"],"sourcesContent":["import type { MeshSlot } from \"./mesh-types\";\n\n/** One remote peer's connection and everything the mesh tracks about it. */\nexport interface PeerLink {\n peerId: string;\n pc: RTCPeerConnection;\n /**\n * Transceivers in slot order.\n *\n * Held explicitly rather than re-read from `getTransceivers()` so slot\n * lookup stays O(1) and cannot drift if a browser reorders.\n */\n transceivers: RTCRtpTransceiver[];\n /** Candidates that arrived before the remote description they belong to. */\n pendingCandidates: RTCIceCandidateInit[];\n /** Whether `setRemoteDescription` has landed, which gates the queue. */\n remoteReady: boolean;\n /** Whether an offer is in flight, so a re-entrant call does not stack one. */\n makingOffer: boolean;\n /** Whether this side offers. Exactly one side of a link does. */\n isOfferer: boolean;\n}\n\n/** Open a connection with the policies a mesh needs. */\nexport function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {\n return new RTCPeerConnection({\n iceServers,\n bundlePolicy: \"max-bundle\",\n rtcpMuxPolicy: \"require\",\n });\n}\n\n/**\n * Resolve which slot a transceiver carries.\n *\n * The offerer allocates the m-lines in slot order, so a transceiver's `mid` is\n * its ordinal and both ends read the same one. That makes `mid` the slot's wire\n * identity, and the **only** identifier correct on both sides of a link.\n *\n * Position in the link's own array is a fallback for a `mid` that is not a plain\n * ordinal, and never the primary: a browser answering an offer appends the\n * negotiated transceivers *after* any it already held, so positional lookup\n * silently resolves to the wrong slot — the camera arriving in the screen tile —\n * or to none at all, dropping the track.\n *\n * @param transceiver - The transceiver a track arrived on.\n * @param link - The link it belongs to.\n * @param slots - The mesh's slot list.\n * @returns The slot, or `null` when neither route names one.\n */\nexport function slotOf(\n transceiver: RTCRtpTransceiver,\n link: PeerLink,\n slots: readonly MeshSlot[],\n): MeshSlot | null {\n const ordinal = Number(transceiver.mid);\n if (transceiver.mid !== null && Number.isInteger(ordinal) && slots[ordinal]) {\n return slots[ordinal];\n }\n const index = link.transceivers.indexOf(transceiver);\n return index >= 0 ? (slots[index] ?? null) : null;\n}\n\n/**\n * Feed a link every candidate that arrived before its remote description.\n *\n * `addIceCandidate` throws while `remoteDescription` is `null`, and candidates\n * routinely beat the offer or answer they belong to. Without the queue the\n * connection loses them and sits in `checking` until it times out — a failure\n * with no error anywhere.\n *\n * @param link - The link whose queue is drained.\n */\nexport async function drainCandidates(link: PeerLink): Promise<void> {\n const queued = link.pendingCandidates.splice(0, link.pendingCandidates.length);\n for (const candidate of queued) {\n try {\n await link.pc.addIceCandidate(candidate);\n } catch {\n /* a candidate the browser rejects is not fatal to the connection */\n }\n }\n}\n\n/**\n * Hand a candidate to the connection, or park it until the description lands.\n *\n * @param link - The link the candidate belongs to.\n * @param init - The candidate, already in `RTCIceCandidateInit` shape.\n */\nexport async function acceptCandidate(link: PeerLink, init: RTCIceCandidateInit): Promise<void> {\n if (!link.remoteReady) {\n link.pendingCandidates.push(init);\n return;\n }\n try {\n await link.pc.addIceCandidate(init);\n } catch {\n /* a candidate the browser rejects is not fatal to the connection */\n }\n}\n\n/**\n * Publish every held local track onto a link's slots.\n *\n * Called once the transceivers exist — up front for the offerer, after the offer\n * lands for the answerer — so a microphone that was already live when the peer\n * joined starts flowing without waiting for a toggle.\n *\n * @param link - The link to publish onto.\n * @param slots - The mesh's slot list.\n * @param tracks - The current local track per slot name.\n */\nexport async function attachLocalTracks(\n link: PeerLink,\n slots: readonly MeshSlot[],\n tracks: Map<string, MediaStreamTrack | null>,\n): Promise<void> {\n for (const [index, slot] of slots.entries()) {\n const transceiver = link.transceivers[index];\n const track = tracks.get(slot.name) ?? null;\n if (!transceiver || !track) continue;\n try {\n await transceiver.sender.replaceTrack(track);\n } catch {\n /* the browser refused the track; the slot simply stays silent */\n }\n }\n}\n\n/**\n * Adopt the transceivers a remote offer created, in `mid` order.\n *\n * The answering side allocates **nothing** up front, and that is the whole\n * subtlety: applying a remote offer creates one transceiver per m-line, in\n * m-line order, and browsers do not reuse transceivers the answerer made\n * beforehand. Pre-allocating leaves dead, never-negotiated transceivers sitting\n * in front of the live ones, which is how slot lookup ends up pointing at the\n * wrong media or at nothing.\n *\n * Each adopted transceiver is switched to `sendrecv` so this side may publish on\n * it too; without that a peer that answered could hear but never be heard.\n *\n * @param link - The link that just applied a remote offer.\n */\nexport function adoptTransceivers(link: PeerLink): void {\n link.transceivers = link.pc\n .getTransceivers()\n .filter((transceiver) => transceiver.mid !== null)\n .sort((a, b) => Number(a.mid) - Number(b.mid));\n for (const transceiver of link.transceivers) {\n if (transceiver.direction !== \"sendrecv\") transceiver.direction = \"sendrecv\";\n }\n}\n"],"mappings":"AAwBA,SAAgB,EAAqB,EAA+C,CAChF,OAAO,IAAI,kBAAkB,CACzB,aACA,aAAc,aACd,cAAe,SACnB,CAAC,CACL,CAoBA,SAAgB,EACZ,EACA,EACA,EACe,CACf,IAAM,EAAU,OAAO,EAAY,GAAG,EACtC,GAAI,EAAY,MAAQ,MAAQ,OAAO,UAAU,CAAO,GAAK,EAAM,GAC/D,OAAO,EAAM,GAEjB,IAAM,EAAQ,EAAK,aAAa,QAAQ,CAAW,EACnD,OAAO,GAAS,EAAK,EAAM,IAAU,KAAQ,IACjD,CAYA,eAAsB,EAAgB,EAA+B,CACjE,IAAM,EAAS,EAAK,kBAAkB,OAAO,EAAG,EAAK,kBAAkB,MAAM,EAC7E,IAAK,IAAM,KAAa,EACpB,GAAI,CACA,MAAM,EAAK,GAAG,gBAAgB,CAAS,CAC3C,MAAQ,CAER,CAER,CAQA,eAAsB,EAAgB,EAAgB,EAA0C,CAC5F,GAAI,CAAC,EAAK,YAAa,CACnB,EAAK,kBAAkB,KAAK,CAAI,EAChC,MACJ,CACA,GAAI,CACA,MAAM,EAAK,GAAG,gBAAgB,CAAI,CACtC,MAAQ,CAER,CACJ,CAaA,eAAsB,EAClB,EACA,EACA,EACa,CACb,IAAK,GAAM,CAAC,EAAO,KAAS,EAAM,QAAQ,EAAG,CACzC,IAAM,EAAc,EAAK,aAAa,GAChC,EAAQ,EAAO,IAAI,EAAK,IAAI,GAAK,KACnC,MAAC,GAAe,CAAC,GACrB,GAAI,CACA,MAAM,EAAY,OAAO,aAAa,CAAK,CAC/C,MAAQ,CAER,CACJ,CACJ,CAiBA,SAAgB,EAAkB,EAAsB,CACpD,EAAK,aAAe,EAAK,GACpB,gBAAgB,CAAC,CACjB,OAAQ,GAAgB,EAAY,MAAQ,IAAI,CAAC,CACjD,MAAM,EAAG,IAAM,OAAO,EAAE,GAAG,EAAI,OAAO,EAAE,GAAG,CAAC,EACjD,IAAK,IAAM,KAAe,EAAK,aACvB,EAAY,YAAc,aAAY,EAAY,UAAY,WAE1E"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
//#region src/webrtc/peer-link.ts
|
|
2
|
+
function e(e) {
|
|
3
|
+
return new RTCPeerConnection({
|
|
4
|
+
iceServers: e,
|
|
5
|
+
bundlePolicy: "max-bundle",
|
|
6
|
+
rtcpMuxPolicy: "require"
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
function t(e, t, n) {
|
|
10
|
+
let r = Number(e.mid);
|
|
11
|
+
if (e.mid !== null && Number.isInteger(r) && n[r]) return n[r];
|
|
12
|
+
let i = t.transceivers.indexOf(e);
|
|
13
|
+
return i >= 0 ? n[i] ?? null : null;
|
|
14
|
+
}
|
|
15
|
+
async function n(e) {
|
|
16
|
+
let t = e.pendingCandidates.splice(0, e.pendingCandidates.length);
|
|
17
|
+
for (let n of t) try {
|
|
18
|
+
await e.pc.addIceCandidate(n);
|
|
19
|
+
} catch {}
|
|
20
|
+
}
|
|
21
|
+
async function r(e, t) {
|
|
22
|
+
if (!e.remoteReady) {
|
|
23
|
+
e.pendingCandidates.push(t);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
await e.pc.addIceCandidate(t);
|
|
28
|
+
} catch {}
|
|
29
|
+
}
|
|
30
|
+
async function i(e, t, n) {
|
|
31
|
+
for (let [r, i] of t.entries()) {
|
|
32
|
+
let t = e.transceivers[r], a = n.get(i.name) ?? null;
|
|
33
|
+
if (!(!t || !a)) try {
|
|
34
|
+
await t.sender.replaceTrack(a);
|
|
35
|
+
} catch {}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function a(e) {
|
|
39
|
+
e.transceivers = e.pc.getTransceivers().filter((e) => e.mid !== null).sort((e, t) => Number(e.mid) - Number(t.mid));
|
|
40
|
+
for (let t of e.transceivers) t.direction !== "sendrecv" && (t.direction = "sendrecv");
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
43
|
+
export { r as acceptCandidate, a as adoptTransceivers, i as attachLocalTracks, e as createPeerConnection, n as drainCandidates, t as slotOf };
|
|
44
|
+
|
|
45
|
+
//# sourceMappingURL=peer-link.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"peer-link.js","names":[],"sources":["../../src/webrtc/peer-link.ts"],"sourcesContent":["import type { MeshSlot } from \"./mesh-types\";\n\n/** One remote peer's connection and everything the mesh tracks about it. */\nexport interface PeerLink {\n peerId: string;\n pc: RTCPeerConnection;\n /**\n * Transceivers in slot order.\n *\n * Held explicitly rather than re-read from `getTransceivers()` so slot\n * lookup stays O(1) and cannot drift if a browser reorders.\n */\n transceivers: RTCRtpTransceiver[];\n /** Candidates that arrived before the remote description they belong to. */\n pendingCandidates: RTCIceCandidateInit[];\n /** Whether `setRemoteDescription` has landed, which gates the queue. */\n remoteReady: boolean;\n /** Whether an offer is in flight, so a re-entrant call does not stack one. */\n makingOffer: boolean;\n /** Whether this side offers. Exactly one side of a link does. */\n isOfferer: boolean;\n}\n\n/** Open a connection with the policies a mesh needs. */\nexport function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {\n return new RTCPeerConnection({\n iceServers,\n bundlePolicy: \"max-bundle\",\n rtcpMuxPolicy: \"require\",\n });\n}\n\n/**\n * Resolve which slot a transceiver carries.\n *\n * The offerer allocates the m-lines in slot order, so a transceiver's `mid` is\n * its ordinal and both ends read the same one. That makes `mid` the slot's wire\n * identity, and the **only** identifier correct on both sides of a link.\n *\n * Position in the link's own array is a fallback for a `mid` that is not a plain\n * ordinal, and never the primary: a browser answering an offer appends the\n * negotiated transceivers *after* any it already held, so positional lookup\n * silently resolves to the wrong slot — the camera arriving in the screen tile —\n * or to none at all, dropping the track.\n *\n * @param transceiver - The transceiver a track arrived on.\n * @param link - The link it belongs to.\n * @param slots - The mesh's slot list.\n * @returns The slot, or `null` when neither route names one.\n */\nexport function slotOf(\n transceiver: RTCRtpTransceiver,\n link: PeerLink,\n slots: readonly MeshSlot[],\n): MeshSlot | null {\n const ordinal = Number(transceiver.mid);\n if (transceiver.mid !== null && Number.isInteger(ordinal) && slots[ordinal]) {\n return slots[ordinal];\n }\n const index = link.transceivers.indexOf(transceiver);\n return index >= 0 ? (slots[index] ?? null) : null;\n}\n\n/**\n * Feed a link every candidate that arrived before its remote description.\n *\n * `addIceCandidate` throws while `remoteDescription` is `null`, and candidates\n * routinely beat the offer or answer they belong to. Without the queue the\n * connection loses them and sits in `checking` until it times out — a failure\n * with no error anywhere.\n *\n * @param link - The link whose queue is drained.\n */\nexport async function drainCandidates(link: PeerLink): Promise<void> {\n const queued = link.pendingCandidates.splice(0, link.pendingCandidates.length);\n for (const candidate of queued) {\n try {\n await link.pc.addIceCandidate(candidate);\n } catch {\n /* a candidate the browser rejects is not fatal to the connection */\n }\n }\n}\n\n/**\n * Hand a candidate to the connection, or park it until the description lands.\n *\n * @param link - The link the candidate belongs to.\n * @param init - The candidate, already in `RTCIceCandidateInit` shape.\n */\nexport async function acceptCandidate(link: PeerLink, init: RTCIceCandidateInit): Promise<void> {\n if (!link.remoteReady) {\n link.pendingCandidates.push(init);\n return;\n }\n try {\n await link.pc.addIceCandidate(init);\n } catch {\n /* a candidate the browser rejects is not fatal to the connection */\n }\n}\n\n/**\n * Publish every held local track onto a link's slots.\n *\n * Called once the transceivers exist — up front for the offerer, after the offer\n * lands for the answerer — so a microphone that was already live when the peer\n * joined starts flowing without waiting for a toggle.\n *\n * @param link - The link to publish onto.\n * @param slots - The mesh's slot list.\n * @param tracks - The current local track per slot name.\n */\nexport async function attachLocalTracks(\n link: PeerLink,\n slots: readonly MeshSlot[],\n tracks: Map<string, MediaStreamTrack | null>,\n): Promise<void> {\n for (const [index, slot] of slots.entries()) {\n const transceiver = link.transceivers[index];\n const track = tracks.get(slot.name) ?? null;\n if (!transceiver || !track) continue;\n try {\n await transceiver.sender.replaceTrack(track);\n } catch {\n /* the browser refused the track; the slot simply stays silent */\n }\n }\n}\n\n/**\n * Adopt the transceivers a remote offer created, in `mid` order.\n *\n * The answering side allocates **nothing** up front, and that is the whole\n * subtlety: applying a remote offer creates one transceiver per m-line, in\n * m-line order, and browsers do not reuse transceivers the answerer made\n * beforehand. Pre-allocating leaves dead, never-negotiated transceivers sitting\n * in front of the live ones, which is how slot lookup ends up pointing at the\n * wrong media or at nothing.\n *\n * Each adopted transceiver is switched to `sendrecv` so this side may publish on\n * it too; without that a peer that answered could hear but never be heard.\n *\n * @param link - The link that just applied a remote offer.\n */\nexport function adoptTransceivers(link: PeerLink): void {\n link.transceivers = link.pc\n .getTransceivers()\n .filter((transceiver) => transceiver.mid !== null)\n .sort((a, b) => Number(a.mid) - Number(b.mid));\n for (const transceiver of link.transceivers) {\n if (transceiver.direction !== \"sendrecv\") transceiver.direction = \"sendrecv\";\n }\n}\n"],"mappings":";AAwBA,SAAgB,EAAqB,GAA+C;CAChF,OAAO,IAAI,kBAAkB;EACzB;EACA,cAAc;EACd,eAAe;CACnB,CAAC;AACL;AAoBA,SAAgB,EACZ,GACA,GACA,GACe;CACf,IAAM,IAAU,OAAO,EAAY,GAAG;CACtC,IAAI,EAAY,QAAQ,QAAQ,OAAO,UAAU,CAAO,KAAK,EAAM,IAC/D,OAAO,EAAM;CAEjB,IAAM,IAAQ,EAAK,aAAa,QAAQ,CAAW;CACnD,OAAO,KAAS,IAAK,EAAM,MAAU,OAAQ;AACjD;AAYA,eAAsB,EAAgB,GAA+B;CACjE,IAAM,IAAS,EAAK,kBAAkB,OAAO,GAAG,EAAK,kBAAkB,MAAM;CAC7E,KAAK,IAAM,KAAa,GACpB,IAAI;EACA,MAAM,EAAK,GAAG,gBAAgB,CAAS;CAC3C,QAAQ,CAER;AAER;AAQA,eAAsB,EAAgB,GAAgB,GAA0C;CAC5F,IAAI,CAAC,EAAK,aAAa;EACnB,EAAK,kBAAkB,KAAK,CAAI;EAChC;CACJ;CACA,IAAI;EACA,MAAM,EAAK,GAAG,gBAAgB,CAAI;CACtC,QAAQ,CAER;AACJ;AAaA,eAAsB,EAClB,GACA,GACA,GACa;CACb,KAAK,IAAM,CAAC,GAAO,MAAS,EAAM,QAAQ,GAAG;EACzC,IAAM,IAAc,EAAK,aAAa,IAChC,IAAQ,EAAO,IAAI,EAAK,IAAI,KAAK;EACnC,OAAC,KAAe,CAAC,IACrB,IAAI;GACA,MAAM,EAAY,OAAO,aAAa,CAAK;EAC/C,QAAQ,CAER;CACJ;AACJ;AAiBA,SAAgB,EAAkB,GAAsB;CACpD,EAAK,eAAe,EAAK,GACpB,gBAAgB,CAAC,CACjB,QAAQ,MAAgB,EAAY,QAAQ,IAAI,CAAC,CACjD,MAAM,GAAG,MAAM,OAAO,EAAE,GAAG,IAAI,OAAO,EAAE,GAAG,CAAC;CACjD,KAAK,IAAM,KAAe,EAAK,cAC3B,AAAI,EAAY,cAAc,eAAY,EAAY,YAAY;AAE1E"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
const e=require("./mesh-quality.cjs"),t=require("./peer-link.cjs");function n(n){let{slots:r,send:i}=n,a=new Map,o=new Map,s=new Map,c=n.iceServers??[],l=n.setLocalDescription??((e,t)=>e.setLocalDescription(t)),u=n.quality??{},d=!1,f=()=>Object.fromEntries(r.map(e=>[e.name,null])),p=()=>{n.onPeers?.([...o.values()].map(e=>({...e})))},m=()=>{if(d)return;let e=[...a.values()].map(e=>e.pc.connectionState);e.some(e=>e===`connected`)?n.onState?.(`connected`):e.length>0?n.onState?.(`connecting`,`negotiating`):n.onState?.(`connected`,`alone`)},h=async t=>{u=t;let n=e.scaleForRoom(t,a.size),i=e.resolveDegradation(t,n);for(let t of a.values())await e.applyQualityToLink(t.transceivers,r,n,i)},g=async e=>{if(!e.makingOffer){e.makingOffer=!0;try{let t=await e.pc.createOffer();if(!t.sdp){n.onNotice?.(`empty_local_offer_sdp`);return}await l(e.pc,t),i({type:`offer`,to:e.peerId,sdp:e.pc.localDescription?.sdp??t.sdp})}catch{n.onNotice?.(`offer_failed`)}finally{e.makingOffer=!1}}},_=e=>e.streams?.[0]||(typeof MediaStream>`u`?null:new MediaStream([e.track])),v=(e,n)=>{let i=t.slotOf(n.transceiver,e,r),a=o.get(e.peerId);!i||!a||(a.streams={...a.streams,[i.name]:_(n)},n.track.onended=()=>{let t=o.get(e.peerId);t&&(t.streams={...t.streams,[i.name]:null},p())},p())},y=e=>{let t=a.get(e);t&&(t.pc.close(),a.delete(e)),o.delete(e),h(u),p(),m()},b=async(e,{offerer:n})=>{if(a.has(e))return;let l=t.createPeerConnection(c),u={peerId:e,pc:l,transceivers:[],pendingCandidates:[],remoteReady:!1,makingOffer:!1,isOfferer:n};a.set(e,u),o.set(e,{peerId:e,connection:l.connectionState,streams:o.get(e)?.streams??f()}),n&&(u.transceivers=r.map(e=>l.addTransceiver(e.kind,{direction:`sendrecv`})),await t.attachLocalTracks(u,r,s)),l.onicecandidate=t=>{i({type:`ice`,to:e,candidate:t.candidate?.candidate??null,sdpMid:t.candidate?.sdpMid??null,sdpMLineIndex:t.candidate?.sdpMLineIndex??null})},l.ontrack=e=>v(u,e),l.onconnectionstatechange=()=>{let t=o.get(e);if(t&&(t.connection=l.connectionState),l.connectionState===`failed`||l.connectionState===`closed`){y(e);return}p(),m()},l.onnegotiationneeded=()=>{u.isOfferer&&g(u)},p(),m(),n&&await g(u)},x=async(e,o)=>{a.has(e)||await b(e,{offerer:!1});let c=a.get(e);if(!c)return;await c.pc.setRemoteDescription({type:`offer`,sdp:o}),c.remoteReady=!0,await t.drainCandidates(c),c.isOfferer||(t.adoptTransceivers(c),await t.attachLocalTracks(c,r,s));let d=await c.pc.createAnswer();if(!d.sdp){n.onNotice?.(`empty_local_answer_sdp`);return}await l(c.pc,d),i({type:`answer`,to:e,sdp:c.pc.localDescription?.sdp??d.sdp}),await h(u)};return n.onState?.(`connecting`,`signaling`),{addPeer:b,removePeer:y,accept:async e=>{if(e.type===`offer`){await x(e.from,e.sdp);return}let n=a.get(e.from);if(n){if(e.type===`answer`){await n.pc.setRemoteDescription({type:`answer`,sdp:e.sdp}),n.remoteReady=!0,await t.drainCandidates(n),await h(u);return}e.candidate!==null&&await t.acceptCandidate(n,{candidate:e.candidate,sdpMid:e.sdpMid??void 0,sdpMLineIndex:e.sdpMLineIndex??void 0})}},setLocalTrack:async(e,t)=>{s.set(e,t);let n=r.findIndex(t=>t.name===e);if(!(n<0)){for(let e of a.values()){let r=e.transceivers[n];if(r)try{await r.sender.replaceTrack(t)}catch{}}await h(u)}},applyQuality:h,setIceServers:e=>{c=e},stop:()=>{for(let e of a.values())e.pc.close();a.clear(),o.clear(),d=!0,p(),n.onState?.(`closed`)},get peers(){return[...o.values()].map(e=>({...e}))}}}exports.createPeerMesh=n;
|
|
2
|
+
//# sourceMappingURL=peer-mesh.cjs.map
|
|
@@ -0,0 +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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"}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { applyQualityToLink as e, resolveDegradation as t, scaleForRoom as n } from "./mesh-quality.js";
|
|
2
|
+
import { acceptCandidate as r, adoptTransceivers as i, attachLocalTracks as a, createPeerConnection as o, drainCandidates as s, slotOf as c } from "./peer-link.js";
|
|
3
|
+
//#region src/webrtc/peer-mesh.ts
|
|
4
|
+
function l(l) {
|
|
5
|
+
let { slots: u, send: d } = l, f = /* @__PURE__ */ new Map(), p = /* @__PURE__ */ new Map(), m = /* @__PURE__ */ new Map(), h = l.iceServers ?? [], g = l.setLocalDescription ?? ((e, t) => e.setLocalDescription(t)), _ = l.quality ?? {}, v = !1, y = () => Object.fromEntries(u.map((e) => [e.name, null])), b = () => {
|
|
6
|
+
l.onPeers?.([...p.values()].map((e) => ({ ...e })));
|
|
7
|
+
}, x = () => {
|
|
8
|
+
if (v) return;
|
|
9
|
+
let e = [...f.values()].map((e) => e.pc.connectionState);
|
|
10
|
+
e.some((e) => e === "connected") ? l.onState?.("connected") : e.length > 0 ? l.onState?.("connecting", "negotiating") : l.onState?.("connected", "alone");
|
|
11
|
+
}, S = async (r) => {
|
|
12
|
+
_ = r;
|
|
13
|
+
let i = n(r, f.size), a = t(r, i);
|
|
14
|
+
for (let t of f.values()) await e(t.transceivers, u, i, a);
|
|
15
|
+
}, C = async (e) => {
|
|
16
|
+
if (!e.makingOffer) {
|
|
17
|
+
e.makingOffer = !0;
|
|
18
|
+
try {
|
|
19
|
+
let t = await e.pc.createOffer();
|
|
20
|
+
if (!t.sdp) {
|
|
21
|
+
l.onNotice?.("empty_local_offer_sdp");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
await g(e.pc, t), d({
|
|
25
|
+
type: "offer",
|
|
26
|
+
to: e.peerId,
|
|
27
|
+
sdp: e.pc.localDescription?.sdp ?? t.sdp
|
|
28
|
+
});
|
|
29
|
+
} catch {
|
|
30
|
+
l.onNotice?.("offer_failed");
|
|
31
|
+
} finally {
|
|
32
|
+
e.makingOffer = !1;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}, w = (e) => e.streams?.[0] || (typeof MediaStream > "u" ? null : new MediaStream([e.track])), T = (e, t) => {
|
|
36
|
+
let n = c(t.transceiver, e, u), r = p.get(e.peerId);
|
|
37
|
+
!n || !r || (r.streams = {
|
|
38
|
+
...r.streams,
|
|
39
|
+
[n.name]: w(t)
|
|
40
|
+
}, t.track.onended = () => {
|
|
41
|
+
let t = p.get(e.peerId);
|
|
42
|
+
t && (t.streams = {
|
|
43
|
+
...t.streams,
|
|
44
|
+
[n.name]: null
|
|
45
|
+
}, b());
|
|
46
|
+
}, b());
|
|
47
|
+
}, E = (e) => {
|
|
48
|
+
let t = f.get(e);
|
|
49
|
+
t && (t.pc.close(), f.delete(e)), p.delete(e), S(_), b(), x();
|
|
50
|
+
}, D = async (e, { offerer: t }) => {
|
|
51
|
+
if (f.has(e)) return;
|
|
52
|
+
let n = o(h), r = {
|
|
53
|
+
peerId: e,
|
|
54
|
+
pc: n,
|
|
55
|
+
transceivers: [],
|
|
56
|
+
pendingCandidates: [],
|
|
57
|
+
remoteReady: !1,
|
|
58
|
+
makingOffer: !1,
|
|
59
|
+
isOfferer: t
|
|
60
|
+
};
|
|
61
|
+
f.set(e, r), p.set(e, {
|
|
62
|
+
peerId: e,
|
|
63
|
+
connection: n.connectionState,
|
|
64
|
+
streams: p.get(e)?.streams ?? y()
|
|
65
|
+
}), t && (r.transceivers = u.map((e) => n.addTransceiver(e.kind, { direction: "sendrecv" })), await a(r, u, m)), n.onicecandidate = (t) => {
|
|
66
|
+
d({
|
|
67
|
+
type: "ice",
|
|
68
|
+
to: e,
|
|
69
|
+
candidate: t.candidate?.candidate ?? null,
|
|
70
|
+
sdpMid: t.candidate?.sdpMid ?? null,
|
|
71
|
+
sdpMLineIndex: t.candidate?.sdpMLineIndex ?? null
|
|
72
|
+
});
|
|
73
|
+
}, n.ontrack = (e) => T(r, e), n.onconnectionstatechange = () => {
|
|
74
|
+
let t = p.get(e);
|
|
75
|
+
if (t && (t.connection = n.connectionState), n.connectionState === "failed" || n.connectionState === "closed") {
|
|
76
|
+
E(e);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
b(), x();
|
|
80
|
+
}, n.onnegotiationneeded = () => {
|
|
81
|
+
r.isOfferer && C(r);
|
|
82
|
+
}, b(), x(), t && await C(r);
|
|
83
|
+
}, O = async (e, t) => {
|
|
84
|
+
f.has(e) || await D(e, { offerer: !1 });
|
|
85
|
+
let n = f.get(e);
|
|
86
|
+
if (!n) return;
|
|
87
|
+
await n.pc.setRemoteDescription({
|
|
88
|
+
type: "offer",
|
|
89
|
+
sdp: t
|
|
90
|
+
}), n.remoteReady = !0, await s(n), n.isOfferer || (i(n), await a(n, u, m));
|
|
91
|
+
let r = await n.pc.createAnswer();
|
|
92
|
+
if (!r.sdp) {
|
|
93
|
+
l.onNotice?.("empty_local_answer_sdp");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
await g(n.pc, r), d({
|
|
97
|
+
type: "answer",
|
|
98
|
+
to: e,
|
|
99
|
+
sdp: n.pc.localDescription?.sdp ?? r.sdp
|
|
100
|
+
}), await S(_);
|
|
101
|
+
};
|
|
102
|
+
return l.onState?.("connecting", "signaling"), {
|
|
103
|
+
addPeer: D,
|
|
104
|
+
removePeer: E,
|
|
105
|
+
accept: async (e) => {
|
|
106
|
+
if (e.type === "offer") {
|
|
107
|
+
await O(e.from, e.sdp);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
let t = f.get(e.from);
|
|
111
|
+
if (t) {
|
|
112
|
+
if (e.type === "answer") {
|
|
113
|
+
await t.pc.setRemoteDescription({
|
|
114
|
+
type: "answer",
|
|
115
|
+
sdp: e.sdp
|
|
116
|
+
}), t.remoteReady = !0, await s(t), await S(_);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
e.candidate !== null && await r(t, {
|
|
120
|
+
candidate: e.candidate,
|
|
121
|
+
sdpMid: e.sdpMid ?? void 0,
|
|
122
|
+
sdpMLineIndex: e.sdpMLineIndex ?? void 0
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
setLocalTrack: async (e, t) => {
|
|
127
|
+
m.set(e, t);
|
|
128
|
+
let n = u.findIndex((t) => t.name === e);
|
|
129
|
+
if (!(n < 0)) {
|
|
130
|
+
for (let e of f.values()) {
|
|
131
|
+
let r = e.transceivers[n];
|
|
132
|
+
if (r) try {
|
|
133
|
+
await r.sender.replaceTrack(t);
|
|
134
|
+
} catch {}
|
|
135
|
+
}
|
|
136
|
+
await S(_);
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
applyQuality: S,
|
|
140
|
+
setIceServers: (e) => {
|
|
141
|
+
h = e;
|
|
142
|
+
},
|
|
143
|
+
stop: () => {
|
|
144
|
+
for (let e of f.values()) e.pc.close();
|
|
145
|
+
f.clear(), p.clear(), v = !0, b(), l.onState?.("closed");
|
|
146
|
+
},
|
|
147
|
+
get peers() {
|
|
148
|
+
return [...p.values()].map((e) => ({ ...e }));
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
//#endregion
|
|
153
|
+
export { l as createPeerMesh };
|
|
154
|
+
|
|
155
|
+
//# sourceMappingURL=peer-mesh.js.map
|
|
@@ -0,0 +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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"}
|