Skip to content

Instantly share code, notes, and snippets.

@aont
Last active June 29, 2025 16:23
Show Gist options
  • Save aont/7f050e97bca4f41e7855b9925747b552 to your computer and use it in GitHub Desktop.
Save aont/7f050e97bca4f41e7855b9925747b552 to your computer and use it in GitHub Desktop.

WebRTC DataChannel Tester with QR Exchange

Purpose To establish a peer-to-peer WebRTC DataChannel between two browsers without a signalling server by exchanging the session description (SDP) through on-screen QR codes.

How to Use

  1. Select a role – One user selects Offerer, the other Answerer.

  2. Generate your SDP

    • Offerer: the page automatically creates an SDP offer.
    • Answerer: click Set Remote after receiving the offer to create an SDP answer.
  3. Transfer the SDP

    • Click QR Create to turn your SDP into one or more QR codes (max. 200 chars each).
    • The peer chooses Start QR Scan and points the camera at the codes until all fragments are received.
  4. Set the remote description – The received SDP is filled in automatically; press Set Remote if needed.

  5. Start chatting – When the DataChannel state shows OPEN, type a message and press Send.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>WebRTC DataChannel Test</title>
<style>
body {font-family:sans-serif;margin:20px}
textarea{width:100%;height:120px}
pre {background:#f5f5f5;padding:8px}
label {font-weight:bold}
#qrLocal,#qrReader{margin-top:6px;padding:4px;border:1px solid #ccc;display:inline-block}
video {width:300px;height:auto}
</style>
<!-- ▼ Libraries -->
<script type="module">
import QRCode from "https://cdn.jsdelivr.net/npm/[email protected]/+esm";
window.QRCode = QRCode; /* Keep compatibility with existing code */
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html5-qrcode/2.3.8/html5-qrcode.min.js"></script>
</head>
<body>
<h2>① Choose Your Role</h2>
<label><input type="radio" name="role" value="offer" checked> Offerer (the side that starts the connection first)</label><br>
<label><input type="radio" name="role" value="answer"> Answerer (the side that receives the invitation)</label>
<h2>② Your SDP</h2>
<textarea id="local" placeholder="Automatically output here after Offerer/Answerer action" readonly></textarea>
<button id="makeQr">Generate QR</button>
<div id="qrLocal"></div>
<h2>③ Paste Peer’s SDP</h2>
<textarea id="remote" placeholder="Paste or scan the SDP generated in the peer browser"></textarea>
<button id="set">Set Remote</button>
<button id="scanQr">Start QR Scan</button>
<button id="stopScan" disabled>Stop</button>
<div id="qrReader"></div>
<h2>④ DataChannel Chat</h2>
<input id="msg" placeholder="Message to send">
<button id="send">Send</button>
<h2>Log</h2>
<pre id="log"></pre>
<script>
/* ---------- WebRTC Basics ---------- */
const pc = new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
let dc;
const log = (...a)=>logEl.textContent += a.join(' ') + '\n';
const local = document.getElementById('local');
const remote = document.getElementById('remote');
const roleRadios = document.querySelectorAll('input[name="role"]');
/* Output SDP when ICE gathering finishes */
pc.onicecandidate = e=>{
if(e.candidate===null){
local.value = JSON.stringify(pc.localDescription);
}
};
/* DataChannel received by the Answerer */
pc.ondatachannel = e=>{ dc = e.channel; setupDataChannel(); };
/* DataChannel for the Offerer */
function createOffererChannel(){
dc = pc.createDataChannel('chat');
setupDataChannel();
}
/* Common DataChannel handlers */
function setupDataChannel(){
dc.onopen = ()=>log('=== OPEN ===');
dc.onclose = ()=>log('=== CLOSE ===');
dc.onmessage= e =>log('peer:', e.data);
}
/* ---------- UI Actions ---------- */
document.getElementById('set').onclick = async()=>{
if(!remote.value.trim()) return;
const desc = JSON.parse(remote.value);
await pc.setRemoteDescription(desc);
if(desc.type === 'offer'){
await pc.setLocalDescription(await pc.createAnswer());
}
};
document.getElementById('send').onclick = ()=>{
if(!dc || dc.readyState!=='open'){ alert('DataChannel is not open'); return; }
const text = msg.value;
if(text){ dc.send(text); log('you:', text); msg.value=''; }
};
/* ---------- Generate QR (split every 200 chars) ---------- */
document.getElementById('makeQr').onclick = ()=>generateQr(local.value);
function generateQr(text){
if(!text){ alert('SDP has not been generated'); return; }
const MAX_PAYLOAD = 200; // Split into ≤ 200-char chunks
const chunks = [];
for(let i=0; i<text.length; i += MAX_PAYLOAD){
chunks.push(text.slice(i, i+MAX_PAYLOAD));
}
const total = chunks.length;
const area = document.getElementById('qrLocal');
area.innerHTML = ''; // Replace content
chunks.forEach((chunk, idx)=>{
const prefix = `${idx+1}/${total}:`; // e.g. "2/5:"
const payload = prefix + chunk; // Fits within 200 chars incl. prefix
QRCode.toCanvas(
payload,
{errorCorrectionLevel:'L', width:256},
(err, canvas)=>{
if(err){ alert(err); return; }
area.appendChild(canvas);
area.appendChild(document.createElement('br')); // Add line break for readability
}
);
});
}
/* ---------- QR Scanning ---------- */
let qrScanner;
const recvMap = new Map(); // key: index(1–n) -> chunk
let expectedTotal = null;
function handleDecoded(text){
/* Parse prefix "idx/total:" */
const m = text.match(/^(\d+)\/(\d+):(.*)$/s);
if(!m){ console.warn('Prefix format is incorrect'); return; }
const idx = Number(m[1]);
const total = Number(m[2]);
const chunk = m[3];
/* Determine total count on first receipt */
if(expectedTotal === null){
expectedTotal = total;
}
/* Prevent session mixing */
if(total !== expectedTotal){
console.warn('Detected QR from a different session - ignored');
return;
}
recvMap.set(idx, chunk);
log(`QR received ${idx}/${total}`);
/* All parts received? */
if(recvMap.size === total){
/* Concatenate in index order */
const fullText = [...recvMap.keys()].sort((a,b)=>a-b).map(k=>recvMap.get(k)).join('');
remote.value = fullText;
log('=== All fragments received ===');
stopScan(); // Auto-stop
recvMap.clear();
expectedTotal = null;
}
}
document.getElementById('scanQr').onclick = ()=>{
if(qrScanner) return; // Prevent double start
qrScanner = new Html5Qrcode('qrReader');
qrScanner.start(
{facingMode:'environment'},
{fps:10, qrbox:250},
decodedText => handleDecoded(decodedText), // ← Replace with receive handler
err => {}
).then(()=>{
document.getElementById('stopScan').disabled = false;
/* Resize video element to camera resolution */
const video = document.querySelector('#qrReader video');
if(video){
const applySize = ()=>{
video.style.width = video.videoWidth + 'px';
video.style.height = video.videoHeight + 'px';
};
if(video.readyState >= 1){
applySize();
}else{
video.addEventListener('loadedmetadata', applySize, {once:true});
}
}
}).catch(e=>{ alert(e); qrScanner=null; });
};
document.getElementById('stopScan').onclick = stopScan;
function stopScan(){
if(qrScanner){
qrScanner.stop().then(()=>{
qrScanner.clear();
qrScanner = null;
document.getElementById('stopScan').disabled = true;
document.getElementById('qrReader').innerHTML = ''; // Clear preview area
});
}
}
/* ---------- Initialization ---------- */
const logEl = document.getElementById('log');
const msg = document.getElementById('msg');
(async()=>{
const role = [...roleRadios].find(r=>r.checked).value;
if(role === 'offer'){
createOffererChannel();
await pc.setLocalDescription(await pc.createOffer());
}
})();
</script>
</body>
</html>
#!/usr/bin/env python3
import http.server, ssl
# openssl req -new -x509 -days 365 -nodes -newkey rsa:2048 -keyout server.key -out server.crt -subj "/C=JP/ST=Tokyo/L=Local/O=Example/OU=Dev/CN=localhost"
CERT = "server.crt"
KEY = "server.key"
Handler = http.server.SimpleHTTPRequestHandler
httpd = http.server.HTTPServer(('', 8443), Handler)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(certfile=CERT, keyfile=KEY)
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
print("Serving HTTPS on https://0.0.0.0:8443/ ...")
httpd.serve_forever()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment