diff --git a/zebra-report/host-your-own.html b/zebra-report/host-your-own.html index b42551e..50abfa0 100644 --- a/zebra-report/host-your-own.html +++ b/zebra-report/host-your-own.html @@ -334,7 +334,56 @@ turn.example.com { passes straight through. That is the entire edge.
-+ The 1:1 voice call (zebra-audio) needs only the + relay and coturn. For zebra-spaces — a host + plus co-hosts plus speakers plus an audience of listeners — you also stand + up two small Go services: +
+:8091.boot evicts the offender from
+ every path. HTTP signaling on :8092, ICE on a single UDP mux
+ port :7882.
+ Both services live in the same repo as zebra-signal:
+ git.unturf.com/engineering/unturf/proxy.unturf.com
+ — under cmd/zebra-spaces-signal/ and cmd/zebra-spaces-sfu/.
+ Public domain. Each ships a systemd unit and standard build target; the deploy
+ pattern mirrors zebra-signal exactly. The Caddyfile gets two more
+ routes:
+
+ flush_interval -1 matters: the SFU pushes renegotiation offers
+ over Server-Sent Events when speakers join or leave, and Caddy will buffer
+ those events into silence without it.
+
+ The SFU advertises a public IP as its host ICE candidate, so set
+ ZEBRA_SFU_NAT1TO1_IP=<your.public.ip> on the unit and open
+ UDP 7882 in your firewall — that single port carries all
+ ICE/RTP via Pion’s UDP mux. No port range like coturn needs.
+
Now the payoff: nobody needs a modified page. The published zebra pages read two URL parameters and fall back to the unturf servers only if @@ -354,7 +403,7 @@ turn.example.com { fetch credentials — as long as your relay and TURN server are reachable.
-
The rendezvous relay is nearly free: it moves a few hundred bytes per call setup
and then idles, so a tiny box pairs thousands of rooms. coturn is the
@@ -374,7 +423,7 @@ turn.example.com {
This stack is open intellectual capital — take it and run a community the
unturf servers will never see or meter. Patch it, harden it, pass it on. Every
@@ -392,8 +441,8 @@ turn.example.com {
diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html
index 196e1c5..d1bc431 100644
--- a/zebra-report/zebra-spaces.html
+++ b/zebra-report/zebra-spaces.html
@@ -245,6 +245,22 @@
+
+
-10 · it's a gift
+11 · it's a gift
room
@@ -524,6 +540,7 @@ async function sfuPublish(){
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ sdp: pc.localDescription.sdp })
});
+ if (res.status === 403){ pc.close(); handleBlocked('publish'); return; }
if (!res.ok){ pc.close(); throw new Error('sfu publish http '+res.status); }
const ans = await res.json();
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
@@ -570,6 +587,7 @@ async function sfuSubscribe(){
const offerRes = await fetch(subUrl, {
method:'POST', headers:{'Content-Type':'application/json'}, body: '{}'
});
+ if (offerRes.status === 403){ pc.close(); handleBlocked('subscribe'); return; }
if (!offerRes.ok){ pc.close(); throw new Error('sfu subscribe http '+offerRes.status); }
const offer = await offerRes.json();
await pc.setRemoteDescription({ type:'offer', sdp: offer.sdp });
@@ -764,6 +782,10 @@ async function joinSpace(){
if (!code){ setStatus('enter a rendezvous code','err'); return; }
if (!myHandle){ setStatus('pick a handle first (under "identity")','err'); $('handle').focus(); return; }
if (ws){ setStatus('already in a space — leave first',null); return; }
+ /* fresh attempt — clear any previous terminal-block state from an
+ * earlier session in a different room */
+ blocked = false;
+ hideNotice();
await refreshTurnCred();
/* mic isn't acquired here — listeners don't broadcast. We grab it
* lazily when our role becomes speaker (or we entered as host). */
@@ -810,6 +832,7 @@ async function handleSignal(raw){
$('dot-call').className='dot ok';
$('btn-leave').disabled = false;
$('sec-room').classList.remove('hidden');
+ renderShareUrl();
onRoleEntered();
renderRoom();
break;
@@ -956,6 +979,8 @@ async function handleSignal(raw){
case 'error':
logLine('err','signal: '+m.message);
setStatus('signal: '+m.message,'err');
+ /* terminal-block signal: stop the reconnect loop and tell the user */
+ if (/blocked/i.test(m.message)) handleBlocked('signal');
break;
}
}
@@ -1223,6 +1248,28 @@ function hideNotice(){
}
$('btn-notice-close').addEventListener('click', hideNotice);
+/* terminal-block handler — called on any "blocked" signal (signal-server
+ * error, SFU 403). Stops every reconnect loop, surfaces a clear notice,
+ * and shuts the session down so the user sees the boot landed instead of
+ * watching their UI spin trying to rejoin a room they can't enter. */
+let blocked = false;
+function handleBlocked(source){
+ if (blocked) return;
+ blocked = true;
+ wantConnected = false;
+ showNotice('You are blocked from this space.', 'warn');
+ logLine('err','blocked ('+source+') — stopping reconnects');
+ setStatus('blocked from this space','err');
+ if (sigReconnect){ clearTimeout(sigReconnect); sigReconnect = null; }
+ if (ws){ try { ws.close(); } catch(_){} ws = null; }
+ for (const u of [...peers.keys()]) tearPeer(u);
+ sfuUnpublish().catch(()=>{});
+ sfuUnsubscribe().catch(()=>{});
+ dropMic();
+ $('btn-leave').disabled = true;
+ $('btn-enter').disabled = false;
+}
+
$('btn-accept-mic').addEventListener('click', () => {
if (!outstandingInvite) return;
send({ type:'accept-mic', epoch: outstandingInvite.epoch });
@@ -1271,6 +1318,28 @@ if (navigator.mediaDevices && navigator.mediaDevices.addEventListener){
* ================================================================== */
$('btn-enter').addEventListener('click', joinSpace);
$('rdv-code').addEventListener('keydown', e=>{ if(e.key==='Enter'){ e.preventDefault(); joinSpace(); } });
+
+/* ?code=… autofills the rendezvous code (used by the share URL). The code
+ * stays in the URL so a refresh keeps you in the same space; if you want
+ * to leave it cleanly, hit the leave button or close the tab. */
+function renderShareUrl(){
+ const code = $('rdv-code').value.trim();
+ if (!code) return;
+ const u = new URL(location.href);
+ u.searchParams.set('code', code);
+ const url = u.toString();
+ $('share-url').value = url;
+ $('sec-share').classList.remove('hidden');
+}
+$('btn-copy-share').addEventListener('click', async () => {
+ const v = $('share-url').value;
+ try { await navigator.clipboard.writeText(v); $('btn-copy-share').textContent = 'copied'; setTimeout(()=>{ $('btn-copy-share').textContent='copy'; }, 1500); }
+ catch(_){ $('share-url').select(); document.execCommand('copy'); }
+});
+{
+ const c = new URLSearchParams(location.search).get('code');
+ if (c) $('rdv-code').value = c;
+}
$('btn-leave').addEventListener('click', async () => {
wantConnected = false;
if (sigReconnect){ clearTimeout(sigReconnect); sigReconnect = null; }
@@ -1282,6 +1351,7 @@ $('btn-leave').addEventListener('click', async () => {
$('sec-room').classList.add('hidden');
$('sec-invite').classList.add('hidden');
$('sec-listener-actions').classList.add('hidden');
+ $('sec-share').classList.add('hidden');
hideNotice();
$('dot-call').className='dot warn';
setStatus('left', null);
@@ -1298,8 +1368,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');