/** * WebRTC Type Definitions * * Contains all types for the WebRTC Manager system. */ // ============================================================================= // Twin Messaging Interface (dependency injection) // ============================================================================= /** * Interface for twin messaging that the WebRTC system depends on. * This allows the WebRTC system to be decoupled from PhyHubClient. */ export interface TwinTransport { /** Send a message to a specific twin */ sendMessage(targetTwinId: string, data: any): Promise; /** Subscribe to receive messages from a twin */ subscribe(twinId: string): Promise; /** Register a callback for messages from a twin */ onMessage(twinId: string, callback: (msg: any) => void): void; /** Unregister a callback for messages from a twin */ offMessage(twinId: string, callback: (msg: any) => void): void; /** The twinId to register onMessage handlers on for incoming WebRTC messages */ twinId: string; } // ============================================================================= // WebRTC Manager Options // ============================================================================= export interface TurnServerConfig { urls: string; username: string; credential: string; } /** * Result of an iceServersProvider call. Either the bare server list, or the list * plus `ttlSeconds` — the lifetime of the (typically short-lived) TURN * credentials it contains. When `ttlSeconds` is given, the manager re-invokes the * provider for fresh credentials before they expire on a later reconnect/rebuild; * omit it for non-expiring server lists (resolved once). */ export interface IceServersResult { iceServers: RTCIceServer[]; ttlSeconds?: number; } export type IceServersProvider = () => Promise | RTCIceServer[] | IceServersResult; export interface WebRTCManagerOptions { /** Enable verbose logging and additional events. Default: false */ verbose?: boolean; /** Use STUN servers for NAT traversal. Default: true */ useStun?: boolean; /** STUN server URLs. Default: Google's public STUN servers */ stunServers?: string[]; /** TURN server configs for relay fallback */ turnServers?: TurnServerConfig[]; /** ICE transport policy. 'all' tries direct + relay, 'relay' forces TURN only. Default: 'all' */ iceTransportPolicy?: RTCIceTransportPolicy; /** * Optional async source for the full ICE server list (STUN + TURN with * credentials). When provided it takes precedence over static stunServers/ * turnServers and is resolved once per connection lifecycle — the intended use * is to fetch short-lived TURN credentials from a server endpoint (e.g. phyhub's * /v1/webrtc/ice-servers) so the secret never ships to the client. A throw or * empty result falls back to the static stun/turn config (so a transient fetch * failure degrades to STUN-only rather than failing the connection). When the * result carries `ttlSeconds`, the manager refreshes before the credentials * expire on a later reconnect. */ iceServersProvider?: IceServersProvider; /** Connection timeout in milliseconds. Default: 15000 */ connectionTimeout?: number; /** Initial retry delay in milliseconds. Default: 1000 */ initialRetryDelay?: number; /** Maximum retry delay in milliseconds. Default: 30000 */ maxRetryDelay?: number; } // ============================================================================= // Event Types // ============================================================================= /** Standard events (always available) */ export type WebRTCStandardEvent = 'connected' | 'disconnected' | 'error'; /** Verbose events (opt-in via options.verbose) */ export type WebRTCVerboseEvent = | 'reconnecting' | 'reconnected' | 'ice-state-change' | 'signaling-state-change' | 'ice-candidate'; export type WebRTCEvent = WebRTCStandardEvent | WebRTCVerboseEvent; export interface WebRTCEventData { connected: { targetTwinId: string; connectionType: 'datachannel' | 'mediastream' }; disconnected: { targetTwinId: string; connectionType: 'datachannel' | 'mediastream' }; error: { targetTwinId?: string; error: Error }; reconnecting: { targetTwinId: string; attempt: number }; reconnected: { targetTwinId: string; attempt: number }; 'ice-state-change': { targetTwinId: string; state: RTCIceConnectionState }; 'signaling-state-change': { targetTwinId: string; state: RTCSignalingState }; 'ice-candidate': { targetTwinId: string; candidate: RTCIceCandidate | null }; } export type WebRTCEventCallback = (data: WebRTCEventData[E]) => void; // ============================================================================= // DataChannel Types // ============================================================================= /** * Abstraction over RTCDataChannel that provides: * - Automatic reconnection handling * - Message buffering during reconnection * - Simplified event API */ export interface PhygridDataChannel { /** Send data through the channel. Accepts string, ArrayBuffer, or objects (auto-serialized to JSON) */ send(data: string | ArrayBuffer | object): void; /** Register a callback for incoming messages */ onMessage(callback: (data: any) => void): void; /** Unregister a message callback */ offMessage(callback: (data: any) => void): void; /** Register a callback for channel close */ onClose(callback: () => void): void; /** Unregister a close callback */ offClose(callback: () => void): void; /** Close the data channel */ close(): void; /** Check if the channel is currently open */ isOpen(): boolean; /** Check if the channel is currently connecting/reconnecting */ isConnecting(): boolean; /** Get the target twin ID this channel is connected to */ getTargetTwinId(): string; /** Get the channel name (for multiple channels to same peer) */ getChannelName(): string; /** * Get the per-session peer id that distinguishes this connection from others * sharing the same logical channel. An initiator (the side that calls * createDataChannel/getDataChannel) always has one — it mints it. It is null * only on a plain responder that received no peerId. */ getPeerId(): string | null; } // ============================================================================= // MediaStream Types // ============================================================================= /** * Extended MediaStreamTrack with optional frame callback (Node.js @roamhq/wrtc) */ export interface ExtendedMediaStreamTrack extends MediaStreamTrack { onFrame?: (frame: any) => void; } /** Track kinds a media connection can negotiate. */ export type MediaTrackKind = 'audio' | 'video'; export interface MediaStreamOptions { /** Direction for media. Default: 'recvonly' for initiator */ direction?: RTCRtpTransceiverDirection; /** Local stream to send (for sendrecv or sendonly) */ localStream?: MediaStream; /** Optional channel name for multiple streams to same peer (default: 'default') */ channelName?: string; /** * Track kinds to negotiate when receiving without a local stream — one * transceiver per kind. Default ['video'] preserves the legacy behaviour for * every existing caller. When set explicitly, every requested kind must * produce a remote track within connectionTimeout or the connection rejects * with MissingMediaTrackError instead of resolving half-connected (asking a * video-only publisher for audio would otherwise hang silently — the * responder answers the audio m-line with nothing and ontrack never fires). */ kinds?: MediaTrackKind[]; } // ============================================================================= // Responder (one-to-many fan-out) Types // ============================================================================= /** * Options for arming a media responder — one local source fanned out to many * simultaneous peers/viewers, each as its own peer connection. */ export interface MediaStreamResponderOptions { /** Optional channel name (default: 'default') */ channelName?: string; /** * Factory that returns a FRESH local MediaStream (with its own track) for each * new peer. Required for sendonly/sendrecv responders. Per @roamhq/wrtc * guidance, give each peer connection its own track from the shared source * (e.g. `source.createTrack()`) rather than reusing one track object — and * never stop the shared source when a single peer leaves. */ createLocalStream?: () => MediaStream; /** Direction for media sent to each peer. Default: 'sendonly' */ direction?: RTCRtpTransceiverDirection; /** Optional cap on concurrent peers. Offers beyond the cap are ignored. */ maxPeers?: number; } /** Options for arming a data-channel responder. */ export interface DataChannelResponderOptions { /** Optional channel name (default: 'default') */ channelName?: string; /** Optional cap on concurrent peers. Offers beyond the cap are ignored. */ maxPeers?: number; } /** * Identity of a connected peer beyond its per-session id. `peerId` distinguishes * individual sessions (two tabs on one device differ); `deviceId` distinguishes * physical devices. deviceId comes from the signaling envelope's sourceDeviceId * and is undefined if the remote didn't send one (e.g. a browser peer). */ export interface PeerInfo { deviceId?: string; } /** Called once per connected peer/viewer with that peer's own stream + identity. */ export type MediaStreamCallback = (stream: PhygridMediaStream, peerId: string, info: PeerInfo) => void; /** Called once per connected peer with that peer's own data channel + identity. */ export type DataChannelCallback = (channel: PhygridDataChannel, peerId: string, info: PeerInfo) => void; /** * Abstraction over MediaStream that provides: * - Automatic reconnection handling * - Track lifecycle management * - Frame activity monitoring */ export interface PhygridMediaStream { /** Get all current tracks */ getTracks(): MediaStreamTrack[]; /** Get the underlying MediaStream object (for use as video srcObject) */ getStream(): MediaStream | null; /** Add a track to send to the remote peer */ addTrack(track: MediaStreamTrack): void; /** Register a callback for when a track is received */ onTrack(callback: (track: MediaStreamTrack) => void): void; /** Unregister a track callback */ offTrack(callback: (track: MediaStreamTrack) => void): void; /** Register a callback for frame data (if available, Node.js only) */ onFrame(callback: (frameData: any) => void): void; /** Unregister a frame callback */ offFrame(callback: (frameData: any) => void): void; /** Register a callback for stream close */ onClose(callback: () => void): void; /** Unregister a close callback */ offClose(callback: () => void): void; /** Close the media stream */ close(): void; /** Check if the stream is currently receiving frames */ isReceivingFrames(): boolean; /** Check if the stream is currently connecting or reconnecting */ isConnecting(): boolean; /** Get the target twin ID this stream is connected to */ getTargetTwinId(): string; /** Get the channel name (for multiple streams to same peer) */ getChannelName(): string; /** * Get the per-session peer id that distinguishes this connection from others * sharing the same logical channel. An initiator (the side that calls * createMediaStream/getMediaStream) always has one — it mints it. It is null * only on a plain responder that received no peerId. */ getPeerId(): string | null; } // ============================================================================= // Connection State Types // ============================================================================= export type ConnectionType = 'datachannel' | 'mediastream'; export interface ConnectionState { targetTwinId: string; connectionType: ConnectionType; isInitiator: boolean; pc: RTCPeerConnection | null; isConnected: boolean; isReconnecting: boolean; reconnectAttempts: number; } // ============================================================================= // Signaling Message Types // ============================================================================= export type SignalingMessageType = 'offer' | 'answer' | 'ice'; /** * Intercepts a peer's outbound signaling (answer/ice) instead of publishing it to * the twin transport. Used by the one-shot HTTP-signaled answer path (WHEP): the * viewer has no socket to receive signaling, so the answer is captured here and * returned in the HTTP response body. When a sink is set, trickle candidate sends * are swallowed (candidates ride embedded in the gathering-complete answer SDP). */ export type SignalingSink = ( type: SignalingMessageType, data: RTCSessionDescriptionInit | RTCIceCandidateInit | RTCIceCandidate, ) => void; export interface SignalingMessage { type: string; // Format: {channelPrefix}-{targetTwinId}:{messageType} data: RTCSessionDescriptionInit | RTCIceCandidateInit; /** * Per-session id that demultiplexes multiple peers sharing one logical * channel. The initiator mints it and stamps every offer/answer/ice with it; * the responder echoes the same id back on its answer/ice. See * responder-fanout.ts for how it drives one-to-many. */ peerId?: string; } // ============================================================================= // Internal Types (for handler classes) // ============================================================================= export interface PeerConnectionConfig { targetTwinId: string; isInitiator: boolean; connectionType: ConnectionType; channelPrefix: string; useStun: boolean; stunServers: string[]; turnServers: TurnServerConfig[]; iceTransportPolicy: RTCIceTransportPolicy; /** * Optional async source for the full ICE server list. Resolved before the * first connection attempt and cached until the credentials near expiry * (`ttlSeconds`), at which point a later reconnect/rebuild re-resolves it. * Takes precedence over stunServers/turnServers; on throw/empty the manager * falls back to the static config. See WebRTCManagerOptions.iceServersProvider. */ iceServersProvider?: IceServersProvider; onConnected: () => void; onDisconnected: () => void; onError: (error: Error) => void; onReconnecting?: (attempt: number) => void; onReconnected?: (attempt: number) => void; onIceStateChange?: (state: RTCIceConnectionState) => void; onSignalingStateChange?: (state: RTCSignalingState) => void; onIceCandidate?: (candidate: RTCIceCandidate | null) => void; /** * Per-session id used to demultiplex peers that share one logical channel. * - Initiator: if omitted, the manager mints a random id; it is stamped on * every outgoing signal and used to ignore signals bearing a different id. * - Responder fan-out: the dispatcher passes the peer's id so this manager * only talks to that one peer. */ peerId?: string; /** * When true, this manager does NOT own the transport subscription or message * handler — an external dispatcher (responder-fanout) owns the single * subscription and feeds demultiplexed messages via ingestSignalingMessage(). * Prevents N per-viewer managers from each re-processing every peer's signals. */ selfManagedSignaling?: boolean; /** * When set, this peer's outbound answer/ice is handed to the sink instead of * published to the transport, and trickle candidate sends are swallowed. Used * by the one-shot HTTP-signaled answer path (see ResponderFanout.ingestExternalOffer). */ signalingSink?: SignalingSink; // Called after peer connection is created but before offer is sent // Use this to add data channels or media tracks that should be in the offer onPeerConnectionCreated?: (pc: RTCPeerConnection) => void | Promise; } export interface DataChannelConfig extends PeerConnectionConfig { onMessage: (data: any) => void; onOpen: () => void; onClose: () => void; } export interface MediaStreamConfig extends PeerConnectionConfig { direction: RTCRtpTransceiverDirection; localStream?: MediaStream; onTrack: (track: MediaStreamTrack) => void; onFrameInactive?: () => void; } // ============================================================================= // One-shot answer errors (HTTP-signaled WHEP path) // ============================================================================= /** * Thrown by WebRTCManager.answerMediaOffer when no media responder is armed for * the requested twin + channel (nobody called onMediaStream/acceptMediaStream). */ export class NoMediaResponderError extends Error { readonly code = 'no-responder-armed'; constructor(targetTwinId: string, channelName: string) { super(`No media responder armed for twin "${targetTwinId}" on channel "${channelName}"`); this.name = 'NoMediaResponderError'; } } /** * Thrown by ResponderFanout.ingestExternalOffer when the fan-out is already at * its maxPeers cap and cannot admit another viewer. */ export class PeerCapReachedError extends Error { readonly code = 'peer-cap-reached'; constructor(peerId: string, maxPeers: number) { super(`Media responder peer cap (${maxPeers}) reached — cannot admit viewer "${peerId}"`); this.name = 'PeerCapReachedError'; } } /** * Thrown by a receiving media connection when the caller explicitly requested * track kinds (MediaStreamOptions.kinds) and one or more of them produced no * remote track within connectionTimeout — e.g. asking a video-only camera for * audio. The publisher answers the unfulfillable m-line with nothing, so * without this the connection would resolve half-connected and the consumer * would wait forever for a track that is never coming. */ export class MissingMediaTrackError extends Error { readonly code = 'missing-media-track'; readonly missingKinds: MediaTrackKind[]; constructor(targetTwinId: string, missingKinds: MediaTrackKind[], timeoutMs: number) { super( `Requested media kind(s) "${missingKinds.join('", "')}" produced no track from twin "${targetTwinId}" within ${timeoutMs}ms`, ); this.name = 'MissingMediaTrackError'; this.missingKinds = missingKinds; } }