WebRTC Composable
Clear and concise description of the problem
This proposal suggests adding a WebRTC composable to the VueUse library to simplify the integration of real-time peer-to-peer communication in Vue 3 applications. The WebRTC API is commonly used for establishing media and data channels between browsers, but its complexity often requires boilerplate code for handling connections, streams, and events. A Vue composable will abstract this complexity, providing an idiomatic, reactive, and easy-to-use interface.
Suggested solution
The following code defines a composable that establishes a peer-to-peer WebRTC data channel between connected peers. Adding new peers require to be connected to a WebSocket signaling server.
useWebRTC.ts
import {
ChannelMessage,
ChannelOpened,
RTCAnswer,
RTCCandidate,
RTCOffer,
Peer,
type DisconnectFromSignaling,
type ExistingPeers,
type NewPeer,
} from "@/types/webRTC";
import { messageIs, MessageType, type Message, type PeerId } from "@/types/common";
export enum WebSocketStatus {
OPEN = "open",
CONNECTING = "connecting",
CLOSED = "closed",
}
export interface useWebRTCOptions {
RTCConfig: RTCConfiguration;
onChannelOpened: (event: { detail: ChannelOpened }) => void;
onChannelMessage: (event: { detail: ChannelMessage }) => void;
}
const config = useRuntimeConfig();
export function useWebRTC(options: useWebRTCOptions) {
const webSocketStatus = ref<WebSocketStatus>(WebSocketStatus.CLOSED);
const peers = reactive<Map<string, Peer>>(new Map());
const useUser = useUserStore();
const url = (peerId: PeerId) => {
return (
(location.protocol === "https:" ? "wss://" : "ws://") +
location.host +
config.app.baseURL +
"api/signaling?userId=" +
peerId
);
};
const webSocket = ref(new WebSocket(url(useUser.id)));
webSocketStatus.value = WebSocketStatus.CONNECTING;
webSocket.value.onopen = () => {
webSocketStatus.value = WebSocketStatus.OPEN;
};
webSocket.value.onclose = () => {
webSocketStatus.value = WebSocketStatus.CLOSED;
};
const createPeerConnection = (peerId: string) => {
if (peerId === useUser.id) return;
if (peers.get(peerId) !== undefined) {
peers.get(peerId)!.isConnectedToSignaling = true;
return;
}
const connection = new RTCPeerConnection(options.RTCConfig);
connection.onconnectionstatechange = () => {
const peer = peers.get(peerId);
if (peer) {
peer.connectionStatus = connection.connectionState;
}
};
connection.onicecandidate = (event) => {
if (event.candidate) {
const candidate = new RTCCandidate(event.candidate, useUser.id, peerId);
webSocket.value.send(JSON.stringify(candidate));
}
};
connection.ondatachannel = (event) => {
const channel = event.channel;
channel.onmessage = (event) => {
const channelMessage = new ChannelMessage(peerId, event.data);
options.onChannelMessage({ detail: channelMessage });
};
channel.onopen = () => {
console.log(`Open received channel with ID: ${channel.id}`);
const channelOpened = new ChannelOpened(peerId);
options.onChannelOpened({ detail: channelOpened });
let peer = peers.get(peerId);
if (peer) {
peer.channelStatus = "open";
}
};
channel.onclose = () => {
let peer = peers.get(peerId);
if (peer) {
peer.channelStatus = "closed";
}
};
channel.onclosing = () => {
let peer = peers.get(peerId);
if (peer) {
peer.channelStatus = "closing";
}
};
peers.get(peerId)!.channel = channel;
};
peers.set(peerId, {
connection,
connectionStatus: "closed",
channelStatus: "closed",
isConnectedToSignaling: true,
});
};
const createOffer = async (RTCDataChannelInit?: RTCDataChannelInit) => {
for (const [peerId, peer] of peers) {
if (peer.channel?.readyState === "open") continue;
let newPeer = peer;
if (peer.connection.connectionState === "closed") {
peers.delete(peerId);
createPeerConnection(peerId);
newPeer = peers.get(peerId)!;
}
const channel = newPeer.connection.createDataChannel("data", RTCDataChannelInit);
channel.onmessage = (event) => {
const channelMessage = new ChannelMessage(peerId, event.data);
options.onChannelMessage({ detail: channelMessage });
};
channel.onopen = () => {
console.log(`Open created channel with ID: ${channel.id}`);
const channelOpened = new ChannelOpened(peerId);
options.onChannelOpened({ detail: channelOpened });
let peer = peers.get(peerId);
if (peer) {
peer.channelStatus = "open";
}
};
channel.onclose = () => {
let peer = peers.get(peerId);
if (peer) {
peer.channelStatus = "closed";
}
};
channel.onclosing = () => {
let peer = peers.get(peerId);
if (peer) {
peer.channelStatus = "closing";
}
};
peers.get(peerId)!.channel = channel;
const offer = await newPeer.connection.createOffer();
await newPeer.connection.setLocalDescription(offer);
const offerMessage = new RTCOffer(offer, useUser.id, peerId);
webSocket.value.send(JSON.stringify(offerMessage));
}
};
/**
* Send a message to a specific peer
* @param peerId The id of the peer to send the message to
* @param message The message to send
* @throws If data channel is not open or peer not found
*/
const sendTo = (peerId: string, message: string) => {
if (peerId === useUser.id) return;
const peer = peers.get(peerId);
if (peer && peer.channel?.readyState === "open") {
console.log(`Sending to peer ${peerId} the message: ${message}`);
peer.channel.send(message);
} else {
throw new Error(`Unable to send the message to peer ${peerId}: data channel is not open or peer not found`);
}
};
/**
* Send a message to all connected peers
* @param message The message to send
* @throws If data channel is not open or peer not found
*/
const send = (peerIds: string[], message: string) => {
if (peerIds.filter((id) => id !== useUser.id).length === 0) return;
console.log(`Sending to peers ${peerIds.filter((id) => id !== useUser.id)} the message: ${message}`);
const missed: string[] = [];
for (const peerId of peerIds.filter((id) => id !== useUser.id)) {
const peer = peers.get(peerId);
if (peer && peer.channel?.readyState === "open") {
peer.channel?.send(message);
} else {
missed.push(peerId);
}
}
if (missed.length > 0) {
throw new Error(
`Unable to send the message to peers ${missed.join(", ")}: data channel is not open or peer not found`,
);
}
};
const reconnectToSignalingServer = async () => {
if (webSocket.value.readyState === WebSocket.CLOSED) {
webSocket.value = new WebSocket(url(useUser.id));
webSocketStatus.value = WebSocketStatus.CONNECTING;
await new Promise<void>((resolve, reject) => {
webSocket.value.onopen = () => {
webSocketStatus.value = WebSocketStatus.OPEN;
resolve();
};
webSocket.value.onerror = (error) => {
webSocketStatus.value = WebSocketStatus.CLOSED;
reject(error);
};
});
webSocket.value.onclose = () => {
webSocketStatus.value = WebSocketStatus.CLOSED;
};
webSocket.value.onmessage = onMessage.bind(webSocket.value);
}
};
const disconnectFromPeer = (peerId: string) => {
const peer = peers.get(peerId);
if (peer) {
peer.channel?.close();
}
};
const disconnectFromAllPeers = () => {
for (const [_, peer] of peers) {
peer.channel?.close();
}
};
const handleOffer = async (message: RTCOffer) => {
if (message.to !== useUser.id) return;
const peer = peers.get(message.from);
if (!peer) {
console.error(`Peer ${message.from} not found when handling offer`);
return;
}
const connection = peer.connection;
await connection.setRemoteDescription(
new RTCSessionDescription({ type: message.offer.type, sdp: message.offer.sdp }),
);
const answer = await connection.createAnswer();
await connection.setLocalDescription(answer);
const answerMessage = new RTCAnswer(answer, useUser.id, message.from);
webSocket.value.send(JSON.stringify(answerMessage));
};
const handleAnswer = async (message: RTCAnswer) => {
if (message.to !== useUser.id) return;
const peer = peers.get(message.from);
if (!peer) {
console.error(`Peer ${message.from} not found when handling answer`);
return;
}
await peer.connection.setRemoteDescription(
new RTCSessionDescription({ type: message.answer.type, sdp: message.answer.sdp }),
);
};
const handleCandidate = async (message: RTCCandidate) => {
if (message.to !== useUser.id) return;
const peer = peers.get(message.from);
if (!peer) {
console.error(`Peer ${message.from} not found when handling candidate`);
return;
}
await peer.connection.addIceCandidate(new RTCIceCandidate(message.candidate));
};
const onMessage = async (event: MessageEvent) => {
const message: Message = JSON.parse(event.data);
const eventIsOffer = (message: Message): message is RTCOffer => messageIs(message, MessageType.RTC_OFFER);
const eventIsAnswer = (message: Message): message is RTCAnswer => messageIs(message, MessageType.RTC_ANSWER);
const eventIsCandidate = (message: Message): message is RTCCandidate =>
messageIs(message, MessageType.RTC_CANDIDATE);
const eventIsNewPeer = (message: Message): message is NewPeer => messageIs(message, MessageType.NEW_PEER);
const eventIsExistingPeers = (message: Message): message is ExistingPeers =>
messageIs(message, MessageType.EXISTING_PEERS);
const eventIsDisconnectFromSignaling = (message: Message): message is DisconnectFromSignaling =>
messageIs(message, MessageType.DISCONNECT_FROM_SIGNALING);
if (eventIsOffer(message)) {
await handleOffer(message);
} else if (eventIsAnswer(message)) {
await handleAnswer(message);
} else if (eventIsCandidate(message)) {
await handleCandidate(message);
} else if (eventIsNewPeer(message)) {
createPeerConnection(message.peerId);
} else if (eventIsExistingPeers(message)) {
for (const peerId of message.peerIds) {
createPeerConnection(peerId);
}
} else if (eventIsDisconnectFromSignaling(message)) {
const peer = peers.get(message.peerId);
if (peer) {
if (["closed", "failed", "disconnected", "new"].includes(peer.connection.connectionState)) {
peer.connection.close();
peer.channel?.close();
peer.connectionStatus = "closed";
peers.delete(message.peerId);
} else {
peer.isConnectedToSignaling = false;
}
}
} else {
console.error("Unknown message", message);
}
};
webSocket.value.onmessage = onMessage.bind(webSocket.value);
return {
peers,
webSocket,
webSocketStatus,
send,
sendTo,
reconnectToSignalingServer,
createOffer,
disconnectFromAllPeers,
disconnectFromPeer,
};
}
@types/webRTC.ts
import { Message, MessageType, type PeerId } from "./common";
/**
* Represents a peer in the WebRTC connection.
*/
export class Peer {
constructor(
public connection: RTCPeerConnection,
public connectionStatus: RTCPeerConnectionState,
public channelStatus: RTCDataChannelState,
public isConnectedToSignaling: boolean,
public channel?: RTCDataChannel,
) {}
}
/**
* Message sent by the signaling server to already connected peers when a new peer connects.
*/
export class NewPeer extends Message {
readonly type = MessageType.NEW_PEER;
constructor(public readonly peerId: PeerId) {
super();
}
}
/**
* Message sent by the signaling server to a new peer when they connect, containing the IDs of all other connected peers.
*/
export class ExistingPeers extends Message {
readonly type = MessageType.EXISTING_PEERS;
constructor(public readonly peerIds: PeerId[]) {
super();
}
}
export abstract class RTCMessage extends Message {
abstract readonly from: PeerId;
abstract readonly to: PeerId;
}
/**
* Message sent by a peer to another peer containing an RTC offer.
*/
export class RTCAnswer extends RTCMessage {
readonly type = MessageType.RTC_ANSWER;
constructor(
public readonly answer: RTCSessionDescriptionInit,
public readonly from: PeerId,
public readonly to: PeerId,
) {
super();
}
}
/**
* Message sent by a peer to another peer containing an RTC answer.
*/
export class RTCOffer extends RTCMessage {
readonly type = MessageType.RTC_OFFER;
constructor(
public readonly offer: RTCSessionDescriptionInit,
public readonly from: PeerId,
public readonly to: PeerId,
) {
super();
}
}
/**
* Message sent by a peer to another peer containing an RTC ICE candidate.
*/
export class RTCCandidate extends RTCMessage {
readonly type = MessageType.RTC_CANDIDATE;
constructor(
public readonly candidate: RTCIceCandidate,
public readonly from: PeerId,
public readonly to: PeerId,
) {
super();
}
}
/**
* Message sent by the signaling server to every connected peer when a peer disconnects from the signaling server.
*/
export class DisconnectFromSignaling extends Message {
readonly type = MessageType.DISCONNECT_FROM_SIGNALING;
constructor(public readonly peerId: PeerId) {
super();
}
}
/**
* Message sent by a peer to another peer through a WebRTC data channel.
*/
export class ChannelMessage extends Message {
readonly type = MessageType.CHANNEL_MESSAGE;
constructor(
public readonly from: PeerId,
public readonly content: string,
) {
super();
}
}
/**
* Message sent by a peer to another peer through a WebRTC data channel when the channel is opened.
*/
export class ChannelOpened extends Message {
readonly type = MessageType.CHANNEL_OPENED;
constructor(public readonly withPeer: PeerId) {
super();
}
}
@types/common.ts
export type PeerId = string;
export enum MessageType {
NEW_PEER = "new-peer",
EXISTING_PEERS = "existing-peers",
RTC_OFFER = "rtc-offer",
RTC_ANSWER = "rtc-answer",
RTC_CANDIDATE = "rtc-candidate",
CHANNEL_MESSAGE = "channel-message",
CHANNEL_OPENED = "channel-opened",
DISCONNECT_FROM_SIGNALING = "disconnect",
CONNECTION_STATE_CHANGE = "connection-state-change",
}
export const messageIs = <T extends Message>(message: Message, type: MessageType): message is T => {
return message.type === type;
};
/**
* Base class for all messages.
*/
export abstract class Message {
abstract type: MessageType;
/**
* Serialize the message to JSON.
*/
serialize() {
return JSON.stringify(this);
}
}
Alternative
While non-Vue libraries offer similar functionality, they lack seamless integration with Vue, making it challenging to implement reactivity and fully leverage Vue's reactive system.
Additional context
No response
Validations
- Follow our Code of Conduct
- Read the Contributing Guidelines.
- Read the docs.
- Check that there isn't already an issue that request the same feature to avoid creating a duplicate.
Source: vueuse/vueuse