#4270·vueuse

WebRTC Composable

Author: ekkaiaaaCreated Oct 7, 2024Updated Aug 20, 2026
Labelsenhancementnew function

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