Hone logo
Hone
Problems

React WebRTC Hook Implementation Challenge

This challenge requires you to build a custom React hook, useWebRTC, that abstracts the complexities of WebRTC peer-to-peer communication. A well-designed hook will simplify the integration of real-time audio, video, and data sharing into React applications, enabling developers to focus on the UI and application logic rather than the intricacies of the WebRTC API.

Problem Description

Your task is to implement a React hook named useWebRTC in TypeScript. This hook should manage the lifecycle of a WebRTC RTCPeerConnection and provide an interface for establishing and managing peer-to-peer connections. The hook should handle signaling, media streams, and offer/answer negotiation.

Key Requirements:

  1. Initialization: The hook should accept an optional config object for RTCPeerConnection.
  2. Local Media: The hook should allow users to provide a local MediaStream (e.g., from getUserMedia) and attach it to the RTCPeerConnection.
  3. Remote Media: The hook should expose the remote MediaStream(s) received from the peer.
  4. Signaling: The hook should not implement a specific signaling server but should provide methods for sending and receiving signaling messages (SDP offers/answers, ICE candidates). The hook will rely on an external signaling mechanism to transport these messages.
  5. Offer/Answer Negotiation: The hook should facilitate the WebRTC offer/answer flow, allowing the component to create offers and answer incoming offers.
  6. ICE Candidate Gathering: The hook should manage the ICE candidate gathering process and provide a way to send collected candidates to the remote peer.
  7. Connection State Management: The hook should track and expose the RTCPeerConnection's connection state (e.g., new, connecting, connected, disconnected, failed, closed).
  8. Error Handling: Basic error handling for media access and peer connection creation should be considered.
  9. Cleanup: The hook must clean up the RTCPeerConnection and any associated resources when the component unmounts.

Expected Behavior:

When the useWebRTC hook is used in a React component:

  • It should initialize an RTCPeerConnection.
  • If a local MediaStream is provided, it should be added to the connection.
  • It should expose a function to create an offer and methods to handle incoming offers.
  • It should expose a function to create an answer and methods to handle incoming answers.
  • It should expose a function to add received ICE candidates.
  • It should provide the remote MediaStream(s) for rendering.
  • It should expose the current connection state.

Edge Cases to Consider:

  • No local media provided.
  • Multiple remote media streams.
  • Network interruptions and reconnections.
  • The remote peer initiating the connection.

Examples

Example 1: Initiating a Connection (Conceptual)

Let's assume we have a simple signaling mechanism (signalingServer) that can send and receive messages.

// Assuming 'signalingServer' is an instance of a signaling client
// that emits 'offer', 'answer', 'candidate', and has a 'send' method.

interface WebRTCHookArgs {
  signalingServer: SignalingServer; // Custom type for your signaling client
  localStream?: MediaStream;
  config?: RTCConfiguration;
}

interface WebRTCHookReturn {
  remoteStream: MediaStream | null;
  connectionState: RTCPeerConnectionState;
  createOffer: () => Promise<RTCSessionDescriptionInit>;
  handleAnswer: (answer: RTCSessionDescriptionInit) => Promise<void>;
  addIceCandidate: (candidate: RTCIceCandidate) => Promise<void>;
}

function useWebRTC({ signalingServer, localStream, config }: WebRTCHookArgs): WebRTCHookReturn {
  // ... hook implementation ...

  const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
  const [connectionState, setConnectionState] = useState<RTCPeerConnectionState>('new');
  const peerConnectionRef = useRef<RTCPeerConnection | null>(null);

  useEffect(() => {
    // Initialize RTCPeerConnection
    peerConnectionRef.current = new RTCPeerConnection(config);

    // Add local stream if available
    if (localStream) {
      localStream.getTracks().forEach(track => {
        peerConnectionRef.current?.addTrack(track, localStream);
      });
    }

    // Event listeners for ICE candidates, track reception, and connection state changes
    peerConnectionRef.current.onicecandidate = (event) => {
      if (event.candidate) {
        signalingServer.send({ type: 'candidate', payload: event.candidate });
      }
    };

    peerConnectionRef.current.ontrack = (event) => {
      setRemoteStream(event.streams[0]);
    };

    peerConnectionRef.current.onconnectionstatechange = () => {
      setConnectionState(peerConnectionRef.current?.connectionState || 'new');
    };

    // Cleanup
    return () => {
      peerConnectionRef.current?.close();
      peerConnectionRef.current = null;
    };
  }, [config, localStream, signalingServer]);

  const createOffer = async (): Promise<RTCSessionDescriptionInit> => {
    if (!peerConnectionRef.current) throw new Error("Peer connection not initialized.");
    const offer = await peerConnectionRef.current.createOffer();
    await peerConnectionRef.current.setLocalDescription(offer);
    return offer;
  };

  const handleAnswer = async (answer: RTCSessionDescriptionInit): Promise<void> => {
    if (!peerConnectionRef.current) throw new Error("Peer connection not initialized.");
    await peerConnectionRef.current.setRemoteDescription(answer);
  };

  const addIceCandidate = async (candidate: RTCIceCandidateInit): Promise<void> => {
    if (!peerConnectionRef.current) throw new Error("Peer connection not initialized.");
    try {
      await peerConnectionRef.current.addIceCandidate(new RTCIceCandidate(candidate));
    } catch (e) {
      console.error("Error adding ICE candidate:", e);
    }
  };

  // Listen for signaling messages
  useEffect(() => {
    const handleSignalingMessage = (message: any) => {
      if (!peerConnectionRef.current) return;

      switch (message.type) {
        case 'offer':
          peerConnectionRef.current.setRemoteDescription(message.payload)
            .then(() => peerConnectionRef.current?.createAnswer())
            .then(answer => peerConnectionRef.current?.setLocalDescription(answer))
            .then(() => {
              if (peerConnectionRef.current?.localDescription) {
                signalingServer.send({ type: 'answer', payload: peerConnectionRef.current.localDescription });
              }
            })
            .catch(console.error);
          break;
        case 'answer':
          handleAnswer(message.payload).catch(console.error);
          break;
        case 'candidate':
          addIceCandidate(message.payload).catch(console.error);
          break;
        default:
          console.warn("Unknown signaling message type:", message.type);
      }
    };

    signalingServer.on('message', handleSignalingMessage);

    return () => {
      signalingServer.off('message', handleSignalingMessage);
    };
  }, [signalingServer]);


  return {
    remoteStream,
    connectionState,
    createOffer,
    handleAnswer,
    addIceCandidate,
  };
}

// In a React Component:
function VideoCallComponent() {
  const [localStream, setLocalStream] = useState<MediaStream | null>(null);
  const signaling = useSignalingServer(); // Assume this hook provides a signaling client

  useEffect(() => {
    navigator.mediaDevices.getUserMedia({ video: true, audio: true })
      .then(stream => setLocalStream(stream))
      .catch(error => console.error('Error accessing media devices.', error));
  }, []);

  const { remoteStream, connectionState, createOffer, addIceCandidate } = useWebRTC({
    signalingServer: signaling,
    localStream: localStream,
  });

  // Logic to handle initiating call
  const startCall = async () => {
    try {
      const offer = await createOffer();
      signaling.send({ type: 'offer', payload: offer });
    } catch (error) {
      console.error("Error starting call:", error);
    }
  };

  // Logic to handle receiving signaling messages (handled internally by the hook now)
  // e.g., signaling.on('message', (msg) => { if (msg.type === 'answer') handleAnswer(msg.payload); else if (msg.type === 'candidate') addIceCandidate(msg.payload); });

  return (
    <div>
      <h2>WebRTC Call</h2>
      <p>Connection State: {connectionState}</p>
      <div>
        <h3>Local Video</h3>
        <video autoPlay muted ref={(video) => { if (video && localStream) video.srcObject = localStream; }} />
      </div>
      <div>
        <h3>Remote Video</h3>
        <video autoPlay ref={(video) => { if (video && remoteStream) video.srcObject = remoteStream; }} />
      </div>
      <button onClick={startCall} disabled={connectionState === 'connecting' || connectionState === 'connected'}>
        Start Call
      </button>
    </div>
  );
}

Explanation: This example illustrates how useWebRTC would be used. A component gets the local stream, initializes the hook with a signaling server instance, and then uses the hook's returned functions (createOffer, addIceCandidate) to manage the WebRTC connection. The hook internally handles the RTCPeerConnection lifecycle, ICE candidate gathering, and remote stream reception.

Example 2: Receiving a Call (Conceptual)

This is handled by the internal logic of the useWebRTC hook in response to signaling messages. When an 'offer' message is received from the signaling server, the hook's internal handler creates an answer and sets the remote description.

// ... (within the useWebRTC hook's internal signaling message handler) ...

case 'offer':
  peerConnectionRef.current.setRemoteDescription(message.payload)
    .then(() => peerConnectionRef.current?.createAnswer())
    .then(answer => peerConnectionRef.current?.setLocalDescription(answer))
    .then(() => {
      if (peerConnectionRef.current?.localDescription) {
        // Send the answer back to the caller via the signaling server
        signalingServer.send({ type: 'answer', payload: peerConnectionRef.current.localDescription });
      }
    })
    .catch(console.error);
  break;

// ...

Explanation: When an offer message arrives, the hook automatically calls setRemoteDescription, creates an answer, sets it as the local description, and then sends this answer back through the signalingServer.

Constraints

  • The hook must be implemented in TypeScript.
  • The hook should not rely on any third-party WebRTC libraries. Standard browser APIs should be used.
  • The signaling mechanism (sending/receiving offers, answers, ICE candidates) is external to the hook. The hook should expose functions to trigger these actions and have callbacks/event listeners to receive them. You do not need to implement the signaling server itself.
  • The solution should be a single hook function.

Notes

  • Consider using useRef to persist the RTCPeerConnection instance across renders without causing re-renders when the connection object itself changes.
  • Remember to handle the cleanup of the RTCPeerConnection in the useEffect's return function to prevent memory leaks.
  • The RTCIceCandidate objects might need to be serialized/deserialized when sent/received via the signaling server. The hook should handle this gracefully.
  • Think about how to manage multiple remote streams if that's a requirement (though for this challenge, handling at least one is sufficient).
  • getUserMedia can throw errors, so consider how your hook might inform the parent component of these issues.
Loading editor...
typescript