/** 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"; 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 } export interface RemoteMediaTimers { setTimeout(callback: () => void, delayMs: number): ReturnType; clearTimeout(handle: ReturnType): 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; peerConnectionConfiguration?: RTCConfiguration; endpoints?: Partial; timers?: RemoteMediaTimers; now?: () => number; reconnectBaseMs?: number; reconnectMaximumMs?: number; 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 interface ViewerStart { /** A current `authorizeMediaSurface` decision with explicit viewer opt-in. */ decision: MediaAuthorizationDecision; video: HTMLVideoElement; } export interface RemoteOfficeMedia { startPresenter(input: PresenterStart): Promise; startViewer(input: ViewerStart): Promise; state(): RemoteMediaState; stop(): Promise; revoke(): Promise; dispose(): Promise; } interface PeerSlot { readonly id: string; readonly connection: RTCPeerConnection; readonly pendingIce: (RTCIceCandidateInit | null)[]; } const ENDPOINTS: RemoteMediaEndpoints = { join: "/api/v1/media/join", signal: "/api/v1/media/signal", events: "/api/v1/media/events", leave: "/api/v1/media/leave", }; 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): RTCSessionDescriptionInit { return { type: signal.descriptionType, sdp: signal.sdp }; } /** * 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; 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 viewer: ViewerStart | null = null; let receiverStream: MediaStream | null = null; let eventAbort: AbortController | null = null; let reconnectTimer: ReturnType | null = null; let renewalTimer: ReturnType | null = null; let reconnectAttempt = 0; let generation = 0; const peers = new Map(); 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 { 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 { 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(); } function resetPeerMesh(): void { for (const id of [...peers.keys()]) closePeer(id); releaseReceiver(); } async function sendSignal(targetParticipantId: string, signal: ScreenShareSignalPayload): Promise { 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; const connection = makePeer(options.peerConnectionConfiguration); const slot: PeerSlot = { id: participant.participantId, connection, pendingIce: [] }; 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"); connection.addTrack(track, presenter!.stream); } 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 { const connection = configurePeer(participant).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 { 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 (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 { 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(); 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; 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 { 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 { 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(); setStatus("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 { 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 { 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 }; await reconcile(response.participants); scheduleRenewal(); void startEvents().catch((reason) => { options.onError?.(asError(reason, "remote media events failed")); scheduleReconnect(); }); } async function startPresenter(input: PresenterStart): Promise { 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") { closeTransport(); grant = null; presenter = null; setStatus("idle"); } throw reason; } } async function startViewer(input: ViewerStart): Promise { 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"); if (!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") { 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") { closeTransport(); grant = null; viewer = null; setStatus("idle"); } throw reason; } } async function leave(): Promise { 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 { if (status === "disposed" || status === "stopped") return; try { await leave(); } finally { presenter = null; viewer = null; setStatus("stopped"); } } async function revoke(): Promise { if (status === "disposed" || status === "revoked") return; try { await leave(); } finally { presenter = null; viewer = null; setStatus("revoked"); } } async function dispose(): Promise { if (status === "disposed") return; try { await leave(); } finally { presenter = null; viewer = null; status = "disposed"; changed(); } } return { startPresenter, startViewer, state, stop, revoke, dispose }; }