1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/media/remoteMedia.ts
T

797 lines
35 KiB
TypeScript

/** Privacy-gated WebRTC transport for the shared office signaling contract. */
import {
advanceScreenShareStreamCursor,
isScreenShareGrantActive,
parseScreenShareServerMessage,
} from "./signalingValidation.ts";
import {
SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION,
type ScreenShareAccessGrant,
type ScreenShareBinding,
type ScreenShareClientMessage,
type ScreenShareCredential,
type ScreenShareParticipant,
type ScreenSharePeerMessage,
type ScreenShareResumeGrant,
type ScreenShareRole,
type ScreenShareServerMessage,
type ScreenShareSignalPayload,
type ScreenShareStreamCursor,
} from "./signalingTypes.ts";
import type { MediaAuthorizationDecision } from "./types.ts";
import {
fetchEphemeralIceConfiguration,
type EphemeralIceConfiguration,
} from "./iceClient.ts";
export type RemoteMediaRole = ScreenShareRole;
export type RemoteMediaStatus = "idle" | "connecting" | "live" | "reconnecting" | "stopped" | "revoked" | "disposed";
export interface RemoteMediaConsent { authorized: boolean; optedIn: boolean }
export interface RemoteMediaState {
role: RemoteMediaRole;
status: RemoteMediaStatus;
binding: ScreenShareBinding;
sessionId: string | null;
participantCount: number;
reconnectAttempt: number;
hasRemoteVideo: boolean;
}
export interface RemoteMediaEndpoints { join: string; signal: string; events: string; leave: string; ice: string }
export interface RemoteMediaTimers {
setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>;
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
}
export interface RemoteOfficeMediaOptions {
role: RemoteMediaRole;
binding: ScreenShareBinding;
/** Preferred: the app's authenticated fetch wrapper. Tokens remain in POST bodies. */
authenticatedFetch?: typeof globalThis.fetch;
fetch?: typeof globalThis.fetch;
peerConnectionFactory?: (configuration?: RTCConfiguration) => RTCPeerConnection;
/** Injectable for tests/deployments; called only with an active in-memory grant. */
iceConfigurationProvider?(authorization: {
binding: ScreenShareBinding;
credential: ScreenShareCredential;
}): Promise<EphemeralIceConfiguration>;
endpoints?: Partial<RemoteMediaEndpoints>;
timers?: RemoteMediaTimers;
now?: () => number;
reconnectBaseMs?: number;
reconnectMaximumMs?: number;
/** Bounded sender defaults for every presenter-to-viewer connection. */
presenterEncodingPolicy?: Partial<ScreenShareEncodingPolicy>;
onStateChange?(state: RemoteMediaState): void;
onError?(error: Error): void;
}
export interface PresenterStart {
consent: RemoteMediaConsent;
/** Caller-owned. Exactly one live video track is published and never stopped here. */
stream: MediaStream;
video: HTMLVideoElement;
}
export type ViewerStart =
| {
/** A current `authorizeMediaSurface` decision with explicit viewer opt-in. */
decision: MediaAuthorizationDecision;
viewerOptIn?: never;
video: HTMLVideoElement;
}
| {
/**
* Literal user action for signaling servers that perform authorization
* during join and intentionally expose no source locator to the client.
*/
decision?: never;
viewerOptIn: true;
video: HTMLVideoElement;
};
export interface RemoteOfficeMedia {
startPresenter(input: PresenterStart): Promise<void>;
startViewer(input: ViewerStart): Promise<void>;
state(): RemoteMediaState;
stop(): Promise<void>;
revoke(): Promise<void>;
dispose(): Promise<void>;
}
export interface ScreenShareEncodingPolicy {
maximumWidth: number;
maximumHeight: number;
maximumFrameRate: number;
maximumBitrateBps: number;
contentHint: "detail" | "text";
degradationPreference: RTCDegradationPreference;
}
export interface ScreenShareEncodingPolicyResult {
contentHintApplied: boolean;
senderParametersApplied: boolean;
}
export const DEFAULT_SCREEN_SHARE_ENCODING_POLICY: Readonly<ScreenShareEncodingPolicy> =
Object.freeze({
maximumWidth: 1_280,
maximumHeight: 720,
maximumFrameRate: 15,
maximumBitrateBps: 1_500_000,
contentHint: "detail",
degradationPreference: "maintain-resolution",
});
interface PeerSlot {
readonly id: string;
readonly connection: RTCPeerConnection;
readonly pendingIce: (RTCIceCandidateInit | null)[];
senderPolicyReady: Promise<void>;
}
const ENDPOINTS: RemoteMediaEndpoints = {
join: "/api/v1/media/join",
signal: "/api/v1/media/signal",
events: "/api/v1/media/events",
leave: "/api/v1/media/leave",
ice: "/api/v1/media/ice",
};
const TIMERS: RemoteMediaTimers = {
setTimeout: (callback, delay) => globalThis.setTimeout(callback, delay),
clearTimeout: (handle) => globalThis.clearTimeout(handle),
};
function copyBinding(value: ScreenShareBinding): ScreenShareBinding {
for (const key of ["officeId", "levelId", "screenId"] as const) {
if (typeof value[key] !== "string" || value[key].length < 1 || value[key].length > 128) {
throw new TypeError(`remote media: invalid ${key}`);
}
}
if (value.roomId !== null && (typeof value.roomId !== "string" || value.roomId.length < 1 || value.roomId.length > 128)) {
throw new TypeError("remote media: invalid roomId");
}
return { ...value };
}
function sameBinding(a: ScreenShareBinding, b: ScreenShareBinding): boolean {
return a.officeId === b.officeId && a.levelId === b.levelId && a.roomId === b.roomId && a.screenId === b.screenId;
}
function asError(reason: unknown, fallback: string): Error { return reason instanceof Error ? reason : new Error(fallback); }
function requestId(number: number): string { return `tera-media-${number}`; }
function sdp(value: RTCSessionDescriptionInit): ScreenShareSignalPayload {
if ((value.type !== "offer" && value.type !== "answer") || typeof value.sdp !== "string") {
throw new Error("remote media: local SDP is invalid");
}
return { kind: "sdp", descriptionType: value.type, sdp: value.sdp };
}
function remoteDescription(signal: Extract<ScreenShareSignalPayload, { kind: "sdp" }>): RTCSessionDescriptionInit {
return { type: signal.descriptionType, sdp: signal.sdp };
}
function finitePositive(value: number | undefined, fallback: number, name: string): number {
const resolved = value ?? fallback;
if (!Number.isFinite(resolved) || resolved <= 0) {
throw new RangeError(`remote media: ${name} must be finite and positive`);
}
return resolved;
}
function resolveEncodingPolicy(value: Partial<ScreenShareEncodingPolicy> | undefined): ScreenShareEncodingPolicy {
const degradationPreference = value?.degradationPreference ??
DEFAULT_SCREEN_SHARE_ENCODING_POLICY.degradationPreference;
if (
degradationPreference !== "balanced" &&
degradationPreference !== "maintain-framerate" &&
degradationPreference !== "maintain-resolution"
) throw new RangeError("remote media: invalid degradation preference");
const contentHint = value?.contentHint ?? DEFAULT_SCREEN_SHARE_ENCODING_POLICY.contentHint;
if (contentHint !== "detail" && contentHint !== "text") {
throw new RangeError("remote media: invalid screen content hint");
}
return {
maximumWidth: finitePositive(value?.maximumWidth, DEFAULT_SCREEN_SHARE_ENCODING_POLICY.maximumWidth, "maximumWidth"),
maximumHeight: finitePositive(value?.maximumHeight, DEFAULT_SCREEN_SHARE_ENCODING_POLICY.maximumHeight, "maximumHeight"),
maximumFrameRate: finitePositive(value?.maximumFrameRate, DEFAULT_SCREEN_SHARE_ENCODING_POLICY.maximumFrameRate, "maximumFrameRate"),
maximumBitrateBps: finitePositive(value?.maximumBitrateBps, DEFAULT_SCREEN_SHARE_ENCODING_POLICY.maximumBitrateBps, "maximumBitrateBps"),
contentHint,
degradationPreference,
};
}
/** Best-effort WebRTC policy: capability differences never abort signaling. */
export async function applyScreenShareEncodingPolicy(
track: MediaStreamTrack,
sender: RTCRtpSender,
policyValue: Partial<ScreenShareEncodingPolicy> = {},
): Promise<ScreenShareEncodingPolicyResult> {
const policy = resolveEncodingPolicy(policyValue);
let contentHintApplied = false;
try {
track.contentHint = policy.contentHint;
contentHintApplied = track.contentHint === policy.contentHint;
} catch { /* Safari versions without writable contentHint still share safely. */ }
try {
if (typeof sender.getParameters !== "function" || typeof sender.setParameters !== "function") {
return { contentHintApplied, senderParametersApplied: false };
}
const parameters = sender.getParameters();
if (!Array.isArray(parameters.encodings) || parameters.encodings.length === 0) {
return { contentHintApplied, senderParametersApplied: false };
}
let width = 0;
let height = 0;
try {
const settings = track.getSettings?.();
width = typeof settings?.width === "number" ? settings.width : 0;
height = typeof settings?.height === "number" ? settings.height : 0;
} catch { /* Some synthetic/browser tracks do not expose settings. */ }
const scaleResolutionDownBy = Math.max(
1,
width > 0 ? width / policy.maximumWidth : 1,
height > 0 ? height / policy.maximumHeight : 1,
);
for (const encoding of parameters.encodings) {
encoding.maxBitrate = Math.floor(policy.maximumBitrateBps);
encoding.maxFramerate = policy.maximumFrameRate;
encoding.scaleResolutionDownBy = scaleResolutionDownBy;
}
// Older Safari builds omit this dictionary member; do not manufacture it
// because their setParameters implementations may reject unknown fields.
if ("degradationPreference" in parameters) {
parameters.degradationPreference = policy.degradationPreference;
}
await sender.setParameters(parameters);
return { contentHintApplied, senderParametersApplied: true };
} catch {
return { contentHintApplied, senderParametersApplied: false };
}
}
/**
* Capture-free and storage-free by construction. The adapter never invokes a
* media acquisition API, calls `play`, records, uploads media, or exposes the
* server grant in state/events. It owns peer connections and received tracks;
* the presenter's input stream, video, and track remain caller-owned.
*/
export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): RemoteOfficeMedia {
if (options.authenticatedFetch && options.fetch) throw new TypeError("remote media: provide one fetch adapter");
const fetcher = options.authenticatedFetch ?? options.fetch ?? globalThis.fetch;
if (typeof fetcher !== "function") throw new TypeError("remote media: fetch is required");
const makePeer = options.peerConnectionFactory ?? ((configuration) => new RTCPeerConnection(configuration));
const binding = copyBinding(options.binding);
const endpoints = { ...ENDPOINTS, ...options.endpoints };
const timers = options.timers ?? TIMERS;
const now = options.now ?? Date.now;
const reconnectBase = options.reconnectBaseMs ?? 500;
const reconnectMaximum = options.reconnectMaximumMs ?? 15_000;
const presenterEncodingPolicy = resolveEncodingPolicy(options.presenterEncodingPolicy);
const provideIce = options.iceConfigurationProvider ?? ((authorization) =>
fetchEphemeralIceConfiguration({
authenticatedFetch: fetcher,
endpoint: endpoints.ice,
now,
binding: authorization.binding,
credential: authorization.credential,
}));
if (!(reconnectBase > 0) || !Number.isFinite(reconnectBase) || reconnectMaximum < reconnectBase) {
throw new RangeError("remote media: invalid reconnect bounds");
}
let status: RemoteMediaStatus = "idle";
let grant: ScreenShareAccessGrant | null = null;
let clientSequence = 0;
let counter = 0;
let cursor: ScreenShareStreamCursor | null = null;
let participants: readonly ScreenShareParticipant[] = [];
let presenter: PresenterStart | null = null;
let presenterOriginalContentHint: string | null = null;
let viewer: ViewerStart | null = null;
let receiverStream: MediaStream | null = null;
let iceConfiguration: EphemeralIceConfiguration | null = null;
let iceAuthorization: ScreenShareCredential | null = null;
let eventAbort: AbortController | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let renewalTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectAttempt = 0;
let generation = 0;
const peers = new Map<string, PeerSlot>();
function state(): RemoteMediaState {
return {
role: options.role,
status,
binding: { ...binding },
sessionId: grant?.credential.sessionId ?? null,
participantCount: peers.size,
reconnectAttempt,
hasRemoteVideo: receiverStream !== null,
};
}
function changed(): void { options.onStateChange?.(state()); }
function setStatus(next: RemoteMediaStatus): void { status = next; changed(); }
function envelope(): { protocolVersion: 1; sequence: number; timestampMs: number } {
const result = { protocolVersion: SCREEN_SHARE_SIGNALING_PROTOCOL_VERSION, sequence: clientSequence, timestampMs: now() };
clientSequence += 1;
return result;
}
async function post(endpoint: string, message: ScreenShareClientMessage, signal?: AbortSignal): Promise<Response> {
return fetcher(endpoint, {
method: "POST",
credentials: "same-origin",
cache: "no-store",
redirect: "error",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(message),
signal,
});
}
async function parsedResponse(response: Response): Promise<ScreenShareServerMessage> {
if (!response.ok) throw new Error(`remote media request failed (${response.status})`);
const parsed = parseScreenShareServerMessage(await response.json() as unknown);
if (!parsed.ok) throw new Error(parsed.error);
return parsed.value;
}
function releaseReceiver(): void {
if (receiverStream) for (const track of receiverStream.getTracks()) track.stop();
if (viewer?.video.srcObject === receiverStream) viewer.video.srcObject = null;
receiverStream = null;
}
function closePeer(id: string): void {
const slot = peers.get(id);
if (!slot) return;
slot.connection.onicecandidate = null;
slot.connection.ontrack = null;
slot.connection.onconnectionstatechange = null;
slot.connection.close();
peers.delete(id);
}
function closeTransport(): void {
generation += 1;
eventAbort?.abort();
eventAbort = null;
if (reconnectTimer !== null) timers.clearTimeout(reconnectTimer);
reconnectTimer = null;
if (renewalTimer !== null) timers.clearTimeout(renewalTimer);
renewalTimer = null;
for (const id of [...peers.keys()]) closePeer(id);
releaseReceiver();
iceConfiguration = null;
iceAuthorization = null;
if (presenterOriginalContentHint !== null) {
const track = presenter?.stream.getVideoTracks()[0];
try { if (track) track.contentHint = presenterOriginalContentHint; } catch { /* best effort */ }
presenterOriginalContentHint = null;
}
}
function resetPeerMesh(): void {
for (const id of [...peers.keys()]) closePeer(id);
releaseReceiver();
}
async function refreshIceConfiguration(expectedGrant: ScreenShareAccessGrant): Promise<void> {
const activeGeneration = generation;
const next = await provideIce({ binding: { ...binding }, credential: { ...expectedGrant.credential } });
if (generation !== activeGeneration || grant !== expectedGrant || !isScreenShareGrantActive(expectedGrant, now())) {
throw new Error("remote media: ICE configuration was superseded");
}
if (!Number.isSafeInteger(next.expiresAtMs) || next.expiresAtMs <= now() || !next.configuration.iceServers?.length) {
throw new Error("remote media: ICE configuration is not active");
}
iceConfiguration = {
configuration: { ...next.configuration, iceServers: next.configuration.iceServers.map((server) => ({ ...server })) },
expiresAtMs: next.expiresAtMs,
};
iceAuthorization = { ...expectedGrant.credential };
}
async function ensureIceConfiguration(): Promise<void> {
if (!grant) throw new Error("remote media: signaling grant is missing");
const credential = grant.credential;
if (iceConfiguration && iceConfiguration.expiresAtMs > now() && iceAuthorization &&
iceAuthorization.sessionId === credential.sessionId &&
iceAuthorization.participantId === credential.participantId &&
iceAuthorization.role === credential.role &&
iceAuthorization.grantToken === credential.grantToken) return;
await refreshIceConfiguration(grant);
}
async function sendSignal(targetParticipantId: string, signal: ScreenShareSignalPayload): Promise<void> {
if (!grant) throw new Error("remote media: signaling grant is missing");
const request: ScreenShareClientMessage = {
type: "screen-share-signal-request",
...envelope(),
binding,
credential: grant.credential,
targetParticipantId,
signal,
};
const response = await post(endpoints.signal, request);
if (!response.ok) throw new Error(`remote media signal failed (${response.status})`);
}
function configurePeer(participant: ScreenShareParticipant): PeerSlot {
const prior = peers.get(participant.participantId);
if (prior) return prior;
const activeGeneration = generation;
if (!iceConfiguration || iceConfiguration.expiresAtMs <= now()) {
throw new Error("remote media: active ICE configuration is required before creating a peer");
}
const connection = makePeer(iceConfiguration.configuration);
let senderPolicyReady = Promise.resolve();
const slot: PeerSlot = {
id: participant.participantId,
connection,
pendingIce: [],
senderPolicyReady,
};
peers.set(slot.id, slot);
connection.onicecandidate = (event) => {
if (generation !== activeGeneration) return;
const signal: ScreenShareSignalPayload = event.candidate
? {
kind: "ice-candidate",
candidate: event.candidate.candidate,
sdpMid: event.candidate.sdpMid,
sdpMLineIndex: event.candidate.sdpMLineIndex,
usernameFragment: event.candidate.usernameFragment,
}
: { kind: "ice-complete" };
void sendSignal(slot.id, signal).catch((reason) => options.onError?.(asError(reason, "remote media ICE failed")));
};
connection.onconnectionstatechange = () => {
if (connection.connectionState === "connected") { reconnectAttempt = 0; setStatus("live"); }
else if (connection.connectionState === "failed" || connection.connectionState === "disconnected") scheduleReconnect();
};
if (options.role === "presenter") {
const track = presenter?.stream.getVideoTracks()[0];
if (!track || track.readyState !== "live") throw new Error("remote media: presenter track ended");
if (presenterOriginalContentHint === null) presenterOriginalContentHint = track.contentHint;
const sender = connection.addTrack(track, presenter!.stream);
senderPolicyReady = applyScreenShareEncodingPolicy(track, sender, presenterEncodingPolicy).then(() => undefined);
slot.senderPolicyReady = senderPolicyReady;
} else {
connection.addTransceiver("video", { direction: "recvonly" });
connection.ontrack = (event) => {
if (generation !== activeGeneration || status === "revoked" || status === "disposed") {
event.track.stop();
return;
}
releaseReceiver();
receiverStream = event.streams[0] ?? new MediaStream([event.track]);
viewer!.video.srcObject = receiverStream;
changed();
};
}
changed();
return slot;
}
async function offer(participant: ScreenShareParticipant): Promise<void> {
const slot = configurePeer(participant);
await slot.senderPolicyReady;
const connection = slot.connection;
const description = await connection.createOffer();
await connection.setLocalDescription(description);
await sendSignal(participant.participantId, sdp(connection.localDescription ?? description));
}
async function reconcile(next: readonly ScreenShareParticipant[]): Promise<void> {
if (!grant) return;
const own = grant.credential.participantId;
const allowed = next.filter((item) => item.participantId !== own && item.role !== options.role);
const ids = new Set(allowed.map((item) => item.participantId));
for (const id of [...peers.keys()]) if (!ids.has(id)) closePeer(id);
participants = allowed;
if (allowed.some((participant) => !peers.has(participant.participantId))) await ensureIceConfiguration();
if (options.role === "presenter") {
for (const participant of allowed) if (!peers.has(participant.participantId)) await offer(participant);
} else {
const presenterPeer = allowed.find((item) => item.role === "presenter");
if (presenterPeer) configurePeer(presenterPeer);
}
changed();
}
async function applyPeerMessage(message: ScreenSharePeerMessage): Promise<void> {
if (!grant || message.sessionId !== grant.credential.sessionId || !sameBinding(message.binding, binding)) return;
const advanced = advanceScreenShareStreamCursor(cursor, message);
if (!advanced.ok) return;
cursor = advanced.value;
if (message.type === "screen-share-stopped") {
closeTransport();
// The server has already destroyed the signaling session. Forget the
// capability now so caller cleanup cannot issue a redundant leave that
// is guaranteed to fail authorization.
grant = null;
setStatus("stopped");
return;
}
if (message.type === "screen-share-revoked") {
if (message.scope === "session" || message.targetParticipantId === grant.credential.participantId) {
closeTransport();
grant = null;
setStatus("revoked");
} else if (message.targetParticipantId) closePeer(message.targetParticipantId);
return;
}
if (message.type === "screen-share-participants") {
await reconcile(message.participants);
return;
}
if (message.targetParticipantId !== grant.credential.participantId) return;
const from = participants.find((item) => item.participantId === message.fromParticipantId);
if (!from) return;
if (!peers.has(from.participantId)) await ensureIceConfiguration();
const connection = configurePeer(from).connection;
if (message.signal.kind === "ice-complete") {
if (connection.remoteDescription) await connection.addIceCandidate(null);
else peers.get(from.participantId)?.pendingIce.push(null);
return;
}
if (message.signal.kind === "ice-candidate") {
const incoming: RTCIceCandidateInit = {
candidate: message.signal.candidate,
sdpMid: message.signal.sdpMid,
sdpMLineIndex: message.signal.sdpMLineIndex,
usernameFragment: message.signal.usernameFragment ?? undefined,
};
if (connection.remoteDescription) await connection.addIceCandidate(incoming);
else peers.get(from.participantId)?.pendingIce.push(incoming);
return;
}
await connection.setRemoteDescription(remoteDescription(message.signal));
const slot = peers.get(from.participantId);
if (slot) {
for (const incoming of slot.pendingIce.splice(0)) await connection.addIceCandidate(incoming);
}
if (message.signal.descriptionType === "offer") {
const answer = await connection.createAnswer();
await connection.setLocalDescription(answer);
await sendSignal(from.participantId, sdp(connection.localDescription ?? answer));
}
}
async function readEvents(response: Response, activeGeneration: number, expectedRequestId: string): Promise<void> {
if (!response.ok || !response.body) throw new Error(`remote media events failed (${response.status})`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let first = true;
try {
while (generation === activeGeneration) {
const chunk = await reader.read();
if (generation !== activeGeneration || chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true }).replace(/\r/g, "");
let boundary = buffer.indexOf("\n\n");
while (boundary >= 0) {
const block = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
boundary = buffer.indexOf("\n\n");
const data = block.split("\n").filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).replace(/^ /, "")).join("\n");
if (!data) continue;
let raw: unknown;
try { raw = JSON.parse(data); } catch { continue; }
const parsed = parseScreenShareServerMessage(raw);
if (!parsed.ok) continue;
if (first) {
first = false;
if (parsed.value.type !== "screen-share-resume-grant" ||
parsed.value.requestId !== expectedRequestId || !(await acceptGrant(parsed.value))) {
throw new Error("remote media: event stream did not start with a valid resume grant");
}
} else if (parsed.value.type === "screen-share-signal-relay" ||
parsed.value.type === "screen-share-participants" ||
parsed.value.type === "screen-share-stopped" ||
parsed.value.type === "screen-share-revoked") {
await applyPeerMessage(parsed.value);
}
}
}
if (generation === activeGeneration && first) throw new Error("remote media: event stream ended before resume grant");
} finally { reader.releaseLock(); }
}
async function acceptGrant(message: ScreenShareResumeGrant): Promise<boolean> {
const current = grant?.credential;
if (!current || message.grant.credential.sessionId !== current.sessionId ||
message.grant.credential.participantId !== current.participantId ||
!sameBinding(message.binding, binding) || message.grant.credential.role !== options.role ||
!isScreenShareGrantActive(message.grant, now())) return false;
if (grant && (message.grant.credential.sessionId !== grant.credential.sessionId ||
message.grant.credential.participantId !== grant.credential.participantId)) return false;
grant = message.grant;
clientSequence = message.nextClientSequence;
cursor = { sessionId: message.grant.credential.sessionId, sequence: message.sequence, timestampMs: message.timestampMs };
if (!message.continuous) resetPeerMesh();
await reconcile(message.participants);
scheduleRenewal();
// A presenter with no viewers is still live: the hosted signaling session
// is accepting authorized viewers. A viewer remains connecting until its
// receive-only peer actually reaches the connected state.
setStatus(options.role === "presenter" && peers.size === 0 ? "live" : "connecting");
return true;
}
function scheduleRenewal(): void {
if (!grant || status === "revoked" || status === "stopped" || status === "disposed") return;
if (renewalTimer !== null) timers.clearTimeout(renewalTimer);
const delay = Math.max(1_000, grant.expiresAtMs - now() - 15_000);
renewalTimer = timers.setTimeout(() => {
renewalTimer = null;
void startEvents().catch((reason) => {
options.onError?.(asError(reason, "remote media lease renewal failed"));
scheduleReconnect();
});
}, delay);
}
function scheduleReconnect(): void {
if (!grant || status === "revoked" || status === "stopped" || status === "disposed" || reconnectTimer !== null) return;
// A failed RTCPeerConnection is not recoverable by renewing the signaling
// lease alone. Tear down the peer mesh and let the resume snapshot rebuild
// it; presenter input tracks remain untouched and reusable.
generation += 1;
eventAbort?.abort();
eventAbort = null;
if (renewalTimer !== null) timers.clearTimeout(renewalTimer);
renewalTimer = null;
resetPeerMesh();
setStatus("reconnecting");
const delay = Math.min(reconnectMaximum, reconnectBase * 2 ** reconnectAttempt);
reconnectAttempt += 1;
reconnectTimer = timers.setTimeout(() => {
reconnectTimer = null;
void startEvents().catch((reason) => { options.onError?.(asError(reason, "remote media reconnect failed")); scheduleReconnect(); });
}, delay);
}
async function startEvents(): Promise<void> {
if (!grant) throw new Error("remote media: grant is missing");
const activeGeneration = generation;
eventAbort?.abort();
const abort = new AbortController();
eventAbort = abort;
const request: ScreenShareClientMessage = {
type: "screen-share-resume-request",
...envelope(),
requestId: requestId(++counter),
binding,
credential: grant.credential,
lastReceivedSequence: cursor?.sequence ?? 0,
};
try {
const response = await post(endpoints.events, request, abort.signal);
await readEvents(response, activeGeneration, request.requestId);
if (generation === activeGeneration) scheduleReconnect();
} catch (reason) {
if (generation !== activeGeneration || abort.signal.aborted) return;
throw reason;
}
}
async function join(role: RemoteMediaRole, activeGeneration: number): Promise<void> {
const id = requestId(++counter);
const message: ScreenShareClientMessage = role === "presenter"
? { type: "screen-share-create-request", ...envelope(), requestId: id, binding, role }
: { type: "screen-share-join-request", ...envelope(), requestId: id, binding, role, viewerOptIn: true };
const response = await parsedResponse(await post(endpoints.join, message));
const expected = role === "presenter" ? "screen-share-create-grant" : "screen-share-join-grant";
if (response.type !== expected || response.requestId !== id || !sameBinding(response.binding, binding) ||
response.grant.credential.role !== role || !isScreenShareGrantActive(response.grant, now())) {
throw new Error("remote media: join grant is invalid");
}
if (generation !== activeGeneration || status === "disposed" || status === "revoked" || status === "stopped") {
throw new Error("remote media: join was superseded");
}
grant = response.grant;
clientSequence = response.nextClientSequence;
cursor = { sessionId: response.grant.credential.sessionId, sequence: response.sequence, timestampMs: response.timestampMs };
// Signaling authorization always precedes TURN. This is the first point at
// which an ICE request can carry a server-issued credential and binding.
await refreshIceConfiguration(response.grant);
await reconcile(response.participants);
scheduleRenewal();
if (role === "presenter" && peers.size === 0) setStatus("live");
void startEvents().catch((reason) => { options.onError?.(asError(reason, "remote media events failed")); scheduleReconnect(); });
}
async function startPresenter(input: PresenterStart): Promise<void> {
if (options.role !== "presenter") throw new Error("remote media: adapter is not a presenter");
if (status === "disposed" || status === "revoked") throw new Error("remote media: session is terminal");
if (!input.consent.authorized || !input.consent.optedIn) throw new Error("remote media: authorization and explicit opt-in are required");
const tracks = input.stream.getVideoTracks().filter((track) => track.readyState === "live");
if (tracks.length !== 1) throw new Error("remote media: presenter requires exactly one live screen track");
input.video.autoplay = false;
input.video.muted = true;
closeTransport();
grant = null;
participants = [];
presenter = input;
viewer = null;
setStatus("connecting");
try {
await join("presenter", generation);
} catch (reason) {
if (status === "connecting") {
// A join can succeed while the grant-bound TURN exchange fails. End
// that just-created hosted session with its in-memory capability
// instead of abandoning it until lease expiry. Caller capture remains
// untouched and can continue as a local preview.
try { await leave(); } catch { closeTransport(); grant = null; participants = []; }
presenter = null;
setStatus("idle");
}
throw reason;
}
}
async function startViewer(input: ViewerStart): Promise<void> {
if (options.role !== "viewer") throw new Error("remote media: adapter is not a viewer");
if (status === "disposed" || status === "revoked") throw new Error("remote media: session is terminal");
const ready = input.decision === undefined
? input.viewerOptIn === true
: input.decision.authorized && input.decision.optedIn && input.decision.canView &&
input.decision.surface.screenId === binding.screenId && input.decision.surface.officeId === binding.officeId &&
input.decision.surface.source?.kind === "live-stream";
if (!ready) {
throw new Error("remote media: a ready live-stream authorization decision is required");
}
input.video.autoplay = false;
input.video.muted = true;
closeTransport();
grant = null;
participants = [];
viewer = input;
presenter = null;
setStatus("connecting");
try {
await join("viewer", generation);
} catch (reason) {
if (status === "connecting") {
try { await leave(); } catch { closeTransport(); grant = null; participants = []; }
viewer = null;
setStatus("idle");
}
throw reason;
}
}
async function leave(): Promise<void> {
const credential = grant?.credential;
closeTransport();
participants = [];
grant = null;
if (!credential) return;
// A presenter ending the whole share is always a stop. `revoke` is a
// policy word locally, not permission to claim a moderator action on wire.
const message: ScreenShareClientMessage = options.role === "presenter"
? {
type: "screen-share-stop-request", ...envelope(), requestId: requestId(++counter), binding,
credential: credential as ScreenShareCredential<"presenter">, reason: "presenter-stopped",
}
: {
type: "screen-share-revoke-request", ...envelope(), requestId: requestId(++counter), binding,
credential, scope: "participant",
targetParticipantId: credential.participantId,
reason: "viewer-left",
};
const response = await post(endpoints.leave, message);
if (!response.ok) throw new Error(`remote media leave failed (${response.status})`);
}
async function stop(): Promise<void> {
if (status === "disposed" || status === "stopped") return;
try { await leave(); } finally { presenter = null; viewer = null; setStatus("stopped"); }
}
async function revoke(): Promise<void> {
if (status === "disposed" || status === "revoked") return;
try { await leave(); } finally { presenter = null; viewer = null; setStatus("revoked"); }
}
async function dispose(): Promise<void> {
if (status === "disposed") return;
try { await leave(); } finally { presenter = null; viewer = null; status = "disposed"; changed(); }
}
return { startPresenter, startViewer, state, stop, revoke, dispose };
}