WebRTC (Baseline Widely available since July 2022) links browsers directly. Peers first swap an SDP offer, an SDP answer and ICE candidates (possible addresses) over a signaling channel you build, such as a WebSocket. STUN reveals each peer's public address; if a firewall blocks the direct path, a TURN server such as coturn relays traffic.

An RTCDataChannel works like a WebSocket between peers. It is reliable and ordered unless you pass { ordered: false, maxRetransmits: 0 }. Here two peers in one page signal by direct calls:
const alice = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
const bob = new RTCPeerConnection();
alice.onicecandidate = ({ candidate }) => candidate && bob.addIceCandidate(candidate);
bob.onicecandidate = ({ candidate }) => candidate && alice.addIceCandidate(candidate);
const chat = alice.createDataChannel('chat');
chat.onopen = () => chat.send('Hello, Bob!');
bob.ondatachannel = ({ channel }) =>
(channel.onmessage = (e) => console.log(`Bob got "${e.data}" via ${channel.label}`));
await alice.setLocalDescription(); // creates the offer
await bob.setRemoteDescription(alice.localDescription);
await bob.setLocalDescription(); // creates the answer
await alice.setRemoteDescription(bob.localDescription);Output
Bob got "Hello, Bob!" via chat