v0.5: stateless room-based sessions with QR pairing

The single shared mesh is replaced by per-session rooms. Visit /
and the server mints a random 8-hex-char id, redirects to /r/<id>.
That URL IS the session — share the link (or scan the QR code now
shown on the page) on another device to join the same room.

Bus is now sharded per room. Rooms are created implicitly on first
subscribe and GC'd 5 minutes after the last subscriber leaves. No
accounts, no persistence, no server-side state beyond the in-memory
bus map.

Server:
- New endpoints: /, /r/<id>, /api/send?room=, /api/stream?room=
- Room manager with lazy creation + idle GC
- Metrics now labelled by room
- New gauge tether_active_rooms

Client (Go):
- -room flag (accepts bare id OR full /r/<id> URL — paste-friendly)
- All API calls now scope to the room
- The always-on ct210-rtc-peer systemd unit is disabled — sessions
  are user-initiated; the user runs tether-client with -room when
  they want their laptop in a particular session

Browser (HTML):
- Reads room from /r/<id> path
- Shows QR code + URL + "copy link" button at top
- "+ new session" link in header to start a fresh room

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Claude Opus 4.7
2026-05-21 01:13:33 -05:00
parent 7995908c87
commit 618d330682
3 changed files with 280 additions and 122 deletions

View File

@@ -53,6 +53,7 @@ type Message struct {
To string `json:"to,omitempty"` To string `json:"to,omitempty"`
Role string `json:"role,omitempty"` Role string `json:"role,omitempty"`
Source string `json:"source,omitempty"` Source string `json:"source,omitempty"`
Room string `json:"room,omitempty"`
TS int64 `json:"ts"` TS int64 `json:"ts"`
} }
@@ -68,16 +69,31 @@ var (
myLabel string myLabel string
useRTC bool useRTC bool
rtcServer string rtcServer string
myRoom string
) )
func main() { func main() {
server := flag.String("server", "https://tether.pecord.io", "tether-server base URL") server := flag.String("server", "https://tether.pecord.io", "tether-server base URL")
label := flag.String("label", "", "X-Tether-Client label (default: <os>-sse-<role>)") label := flag.String("label", "", "X-Tether-Client label (default: <os>-sse-<role>)")
sendText := flag.String("send", "", "send this text and exit (otherwise listen)") sendText := flag.String("send", "", "send this text and exit (otherwise listen)")
roomFlag := flag.String("room", "", "room id to join (or full URL like https://tether.pecord.io/r/<id>)")
flag.BoolVar(&noClipboard, "no-clipboard", false, "don't write incoming messages to the OS clipboard") flag.BoolVar(&noClipboard, "no-clipboard", false, "don't write incoming messages to the OS clipboard")
flag.BoolVar(&useRTC, "rtc", false, "act as a WebRTC mesh peer") flag.BoolVar(&useRTC, "rtc", false, "act as a WebRTC mesh peer")
flag.Parse() flag.Parse()
// Accept either bare id or full URL
myRoom = *roomFlag
if strings.Contains(myRoom, "/r/") {
parts := strings.SplitN(myRoom, "/r/", 2)
if len(parts) == 2 {
myRoom = strings.SplitN(parts[1], "/", 2)[0]
}
}
if myRoom == "" {
fmt.Fprintln(os.Stderr, "tether-client: -room is required (e.g. -room abc12345 or paste the /r/<id> URL)")
os.Exit(2)
}
if *label == "" { if *label == "" {
if *sendText != "" { if *sendText != "" {
*label = defaultLabel("sender") *label = defaultLabel("sender")
@@ -128,8 +144,11 @@ func sendMessage(server, label string, m Message) {
} }
func postMessage(server, label string, m Message) { func postMessage(server, label string, m Message) {
if m.Room == "" {
m.Room = myRoom
}
body, _ := json.Marshal(m) body, _ := json.Marshal(m)
req, _ := http.NewRequest("POST", server+"/api/send", bytes.NewReader(body)) req, _ := http.NewRequest("POST", server+"/api/send?room="+myRoom, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Tether-Source", label) req.Header.Set("X-Tether-Source", label)
r, err := http.DefaultClient.Do(req) r, err := http.DefaultClient.Do(req)
@@ -149,7 +168,7 @@ func postMessage(server, label string, m Message) {
// listen subscribes to the SSE stream and dispatches messages to handlers. // listen subscribes to the SSE stream and dispatches messages to handlers.
func listen(server, label string) error { func listen(server, label string) error {
req, _ := http.NewRequest("GET", server+"/api/stream", nil) req, _ := http.NewRequest("GET", server+"/api/stream?room="+myRoom, nil)
req.Header.Set("X-Tether-Client", label) req.Header.Set("X-Tether-Client", label)
r, err := http.DefaultClient.Do(req) r, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {

View File

@@ -1,20 +1,33 @@
// tether-server v0.3: HTTP+SSE relay with extensible message envelope. // tether-server v0.5: room-scoped, stateless mesh signaling.
// //
// The same /api/send + /api/stream pipeline carries TWO message kinds: // Each session is a "room" identified by a short random ID. The room
// - "clipboard" — the user-facing payload (text) // exists implicitly while at least one subscriber is connected; goes
// - "signal" — WebRTC SDP/ICE for peer negotiation // away once empty (no persistence, no accounts).
// //
// Peers filter by .Type on the client side. Server is neutral relay. // URL shape:
// / → landing; JS picks a room id, redirects to /r/<id>
// /r/<id> → mesh page scoped to room <id>
// /api/send → POST { type, text|signal, from, to, source, room }
// /api/stream → SSE; query ?room=<id>
// /metrics → Prometheus
//
// Message types on the bus:
// "clipboard" user payload
// "signal" WebRTC SDP / ICE (envelope.signal)
// "presence" chirp; { from, role, ... }
package main package main
import ( import (
"crypto/rand"
"embed" "embed"
"encoding/hex"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
"io/fs" "io/fs"
"log" "log"
"net/http" "net/http"
"strings"
"sync" "sync"
"time" "time"
@@ -23,17 +36,20 @@ import (
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
) )
//go:embed web/index.html //go:embed web
var webFS embed.FS var webFS embed.FS
// Message envelope. Type defaults to "clipboard" for backward compat. // Message envelope.
type Message struct { type Message struct {
Type string `json:"type,omitempty"` // "clipboard" | "signal" Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"` // clipboard text Text string `json:"text,omitempty"`
Signal json.RawMessage `json:"signal,omitempty"` // {kind:offer|answer|ice, ...} Signal json.RawMessage `json:"signal,omitempty"`
From string `json:"from,omitempty"` // sender peer id (for signal filtering) From string `json:"from,omitempty"`
Source string `json:"source,omitempty"` // human-readable label To string `json:"to,omitempty"`
TS int64 `json:"ts"` Role string `json:"role,omitempty"`
Source string `json:"source,omitempty"`
Room string `json:"room,omitempty"`
TS int64 `json:"ts"`
} }
type bus struct { type bus struct {
@@ -48,7 +64,6 @@ func (b *bus) subscribe(label string) chan Message {
ch := make(chan Message, 32) ch := make(chan Message, 32)
b.mu.Lock() b.mu.Lock()
b.clients[ch] = label b.clients[ch] = label
// only replay clipboard messages — signals are time-sensitive
for _, m := range b.history { for _, m := range b.history {
if m.Type == "" || m.Type == "clipboard" { if m.Type == "" || m.Type == "clipboard" {
select { select {
@@ -58,7 +73,6 @@ func (b *bus) subscribe(label string) chan Message {
} }
} }
b.mu.Unlock() b.mu.Unlock()
subscribers.Inc()
return ch return ch
} }
@@ -67,7 +81,6 @@ func (b *bus) unsubscribe(ch chan Message) {
delete(b.clients, ch) delete(b.clients, ch)
b.mu.Unlock() b.mu.Unlock()
close(ch) close(ch)
subscribers.Dec()
} }
func (b *bus) publish(m Message) { func (b *bus) publish(m Message) {
@@ -87,23 +100,92 @@ func (b *bus) publish(m Message) {
} }
} }
func (b *bus) size() int {
b.mu.Lock()
defer b.mu.Unlock()
return len(b.clients)
}
// Room registry — implicit creation, GC empty rooms after a grace period.
type rooms struct {
mu sync.Mutex
byID map[string]*bus
emptyAt map[string]time.Time
graceTime time.Duration
}
func newRooms() *rooms {
return &rooms{
byID: map[string]*bus{},
emptyAt: map[string]time.Time{},
graceTime: 5 * time.Minute,
}
}
func (r *rooms) get(id string) *bus {
r.mu.Lock()
defer r.mu.Unlock()
b, ok := r.byID[id]
if !ok {
b = newBus()
r.byID[id] = b
activeRooms.Inc()
}
delete(r.emptyAt, id)
return b
}
func (r *rooms) noteEmpty(id string) {
r.mu.Lock()
defer r.mu.Unlock()
if b, ok := r.byID[id]; ok && b.size() == 0 {
r.emptyAt[id] = time.Now()
}
}
func (r *rooms) gcLoop() {
t := time.NewTicker(time.Minute)
defer t.Stop()
for range t.C {
r.mu.Lock()
for id, since := range r.emptyAt {
if time.Since(since) > r.graceTime {
delete(r.byID, id)
delete(r.emptyAt, id)
activeRooms.Dec()
}
}
r.mu.Unlock()
}
}
func newRoomID() string {
b := make([]byte, 4) // 8 hex chars — 4 billion combinations, short enough to share
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// Prometheus metrics // Prometheus metrics
var ( var (
messages = promauto.NewCounterVec(prometheus.CounterOpts{ messages = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "tether_messages_total", Name: "tether_messages_total",
Help: "Total messages published to the broadcast bus, by source and type.", Help: "Total messages published, by source / type / room.",
}, []string{"source", "type"}) }, []string{"source", "type", "room"})
bytesIn = promauto.NewCounter(prometheus.CounterOpts{ bytesIn = promauto.NewCounter(prometheus.CounterOpts{
Name: "tether_message_bytes_total", Name: "tether_message_bytes_total",
Help: "Total bytes of clipboard text published.", Help: "Total bytes of clipboard text published.",
}) })
activeRooms = promauto.NewGauge(prometheus.GaugeOpts{
Name: "tether_active_rooms",
Help: "Currently-active rooms (have at least one subscriber).",
})
subscribers = promauto.NewGauge(prometheus.GaugeOpts{ subscribers = promauto.NewGauge(prometheus.GaugeOpts{
Name: "tether_active_subscribers", Name: "tether_active_subscribers",
Help: "Number of currently-connected SSE subscribers.", Help: "Total SSE subscribers across all rooms.",
}) })
publishLatency = promauto.NewHistogram(prometheus.HistogramOpts{ publishLatency = promauto.NewHistogram(prometheus.HistogramOpts{
Name: "tether_publish_duration_seconds", Name: "tether_publish_duration_seconds",
Help: "Latency of the publish() fan-out, including channel sends.", Help: "Latency of publish() fan-out.",
Buckets: prometheus.ExponentialBuckets(0.0001, 4, 8), Buckets: prometheus.ExponentialBuckets(0.0001, 4, 8),
}) })
) )
@@ -112,13 +194,40 @@ func main() {
addr := flag.String("addr", ":8765", "listen address") addr := flag.String("addr", ":8765", "listen address")
flag.Parse() flag.Parse()
b := newBus() rm := newRooms()
go rm.gcLoop()
sub, _ := fs.Sub(webFS, "web") sub, _ := fs.Sub(webFS, "web")
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/", http.FileServer(http.FS(sub))) mux.Handle("/static/", http.FileServer(http.FS(sub)))
mux.Handle("/metrics", promhttp.Handler()) mux.Handle("/metrics", promhttp.Handler())
// Landing: pick a room and 302 to /r/<id>.
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.Redirect(w, r, "/r/"+newRoomID(), http.StatusFound)
return
}
http.NotFound(w, r)
})
// Room page — same HTML for any room id. Client reads room from URL.
mux.HandleFunc("/r/", func(w http.ResponseWriter, r *http.Request) {
// Accept /r/<id> only (no further path segments)
id := strings.TrimPrefix(r.URL.Path, "/r/")
if id == "" || strings.Contains(id, "/") {
http.Redirect(w, r, "/r/"+newRoomID(), http.StatusFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
f, err := webFS.ReadFile("web/index.html")
if err != nil {
http.Error(w, "missing index.html", http.StatusInternalServerError)
return
}
w.Write(f)
})
mux.HandleFunc("/api/send", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/send", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed) http.Error(w, "POST only", http.StatusMethodNotAllowed)
@@ -129,6 +238,13 @@ func main() {
http.Error(w, "bad json", http.StatusBadRequest) http.Error(w, "bad json", http.StatusBadRequest)
return return
} }
if m.Room == "" {
m.Room = r.URL.Query().Get("room")
}
if m.Room == "" {
http.Error(w, "missing room", http.StatusBadRequest)
return
}
if m.Type == "" { if m.Type == "" {
m.Type = "clipboard" m.Type = "clipboard"
} }
@@ -140,15 +256,13 @@ func main() {
} }
m.TS = time.Now().UnixMilli() m.TS = time.Now().UnixMilli()
b := rm.get(m.Room)
t0 := time.Now() t0 := time.Now()
b.publish(m) b.publish(m)
publishLatency.Observe(time.Since(t0).Seconds()) publishLatency.Observe(time.Since(t0).Seconds())
messages.WithLabelValues(m.Source, m.Type).Inc() messages.WithLabelValues(m.Source, m.Type, m.Room).Inc()
if m.Type == "clipboard" { if m.Type == "clipboard" {
bytesIn.Add(float64(len(m.Text))) bytesIn.Add(float64(len(m.Text)))
log.Printf("publish clipboard: %s len=%d", m.Source, len(m.Text))
} else {
log.Printf("publish %s: from=%s", m.Type, m.From)
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
}) })
@@ -159,6 +273,11 @@ func main() {
http.Error(w, "no flusher", http.StatusInternalServerError) http.Error(w, "no flusher", http.StatusInternalServerError)
return return
} }
room := r.URL.Query().Get("room")
if room == "" {
http.Error(w, "missing ?room=", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")
@@ -168,9 +287,18 @@ func main() {
if label == "" { if label == "" {
label = r.RemoteAddr label = r.RemoteAddr
} }
b := rm.get(room)
ch := b.subscribe(label) ch := b.subscribe(label)
defer b.unsubscribe(ch) subscribers.Inc()
log.Printf("subscribe: %s", label) defer func() {
b.unsubscribe(ch)
subscribers.Dec()
if b.size() == 0 {
rm.noteEmpty(room)
}
}()
log.Printf("subscribe room=%s label=%s", room, label)
ka := time.NewTicker(30 * time.Second) ka := time.NewTicker(30 * time.Second)
defer ka.Stop() defer ka.Stop()
@@ -178,15 +306,14 @@ func main() {
for { for {
select { select {
case <-r.Context().Done(): case <-r.Context().Done():
log.Printf("unsubscribe: %s", label)
return return
case m := <-ch: case m := <-ch:
bs, _ := json.Marshal(m) bs, _ := json.Marshal(m)
eventName := m.Type ev := m.Type
if eventName == "" { if ev == "" {
eventName = "clipboard" ev = "clipboard"
} }
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventName, bs) fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev, bs)
fl.Flush() fl.Flush()
case <-ka.C: case <-ka.C:
fmt.Fprintf(w, ": keepalive\n\n") fmt.Fprintf(w, ": keepalive\n\n")

View File

@@ -16,17 +16,46 @@
padding: 18px 16px env(safe-area-inset-bottom); padding: 18px 16px env(safe-area-inset-bottom);
gap: 14px; gap: 14px;
} }
header { display: flex; align-items: baseline; gap: 8px; } header { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
h1 { margin: 0; font-size: 22px; font-weight: 600; letter-spacing: -0.5px; } h1 { margin: 0; font-size: 22px; font-weight: 600; letter-spacing: -0.5px; }
.tag { font-size: 11px; color: #888; letter-spacing: 0.5px; text-transform: uppercase; } .tag { font-size: 11px; color: #888; letter-spacing: 0.5px; text-transform: uppercase; }
.pill { .pill { font-size: 10px; padding: 2px 8px; border-radius: 999px; background: #1f1f1f; color: #888; letter-spacing: 0.4px; }
font-size: 10px; padding: 2px 8px; border-radius: 999px;
background: #1f1f1f; color: #888; letter-spacing: 0.4px;
}
.pill.live { background: #052e16; color: #4ade80; } .pill.live { background: #052e16; color: #4ade80; }
.pill.connecting { background: #1f2937; color: #fbbf24; } .pill.connecting { background: #1f2937; color: #fbbf24; }
.room {
font: 12px ui-monospace, "SF Mono", monospace;
padding: 2px 8px; border-radius: 6px;
background: #1a1a1a; color: #a3a3a3;
cursor: pointer; user-select: all;
}
.room:hover { color: #f5f5f5; }
.row { display: flex; flex-direction: column; gap: 6px; } .row { display: flex; flex-direction: column; gap: 6px; }
label { font-size: 11px; color: #888; letter-spacing: 0.4px; text-transform: uppercase; } label { font-size: 11px; color: #888; letter-spacing: 0.4px; text-transform: uppercase; }
.share {
display: flex; gap: 12px; align-items: center;
background: #131313; border: 1px solid #1f1f1f; border-radius: 10px;
padding: 12px;
}
.share .qr {
width: 110px; height: 110px;
flex-shrink: 0;
background: #fff; padding: 8px; border-radius: 6px;
}
.share .qr img { width: 100%; height: 100%; }
.share .info { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.share .info small { color: #888; font-size: 11px; letter-spacing: 0.4px; text-transform: uppercase; }
.share .info .url {
font: 13px ui-monospace, "SF Mono", monospace; color: #d4d4d4;
word-break: break-all; user-select: all;
}
.share .info button {
align-self: flex-start; font-size: 12px; padding: 6px 12px;
background: #1a1a1a; color: #d4d4d4;
border: 1px solid #2a2a2a; border-radius: 6px; cursor: pointer;
}
.share .info button:hover { background: #222; }
textarea { textarea {
width: 100%; min-height: 130px; resize: vertical; width: 100%; min-height: 130px; resize: vertical;
font: 15px -apple-system, ui-monospace, "SF Mono", monospace; font: 15px -apple-system, ui-monospace, "SF Mono", monospace;
@@ -63,7 +92,7 @@
} }
.meta { font-size: 11px; color: #666; margin-top: 4px; } .meta { font-size: 11px; color: #666; margin-top: 4px; }
.meta .rtc-badge { color: #4ade80; } .meta .rtc-badge { color: #4ade80; }
.peers { font-size: 11px; color: #666; margin-top: 4px; display: flex; flex-wrap: wrap; gap: 6px; } .peers { font-size: 11px; color: #666; display: flex; flex-wrap: wrap; gap: 6px; }
.peers .peer { background: #131313; padding: 2px 8px; border-radius: 999px; border: 1px solid #1f1f1f; } .peers .peer { background: #131313; padding: 2px 8px; border-radius: 999px; border: 1px solid #1f1f1f; }
.peers .peer.rtc { color: #4ade80; border-color: #052e16; } .peers .peer.rtc { color: #4ade80; border-color: #052e16; }
footer { footer {
@@ -78,8 +107,19 @@
<h1>tether</h1> <h1>tether</h1>
<span class="tag">mesh clipboard</span> <span class="tag">mesh clipboard</span>
<span class="pill" id="rtcPill">sse</span> <span class="pill" id="rtcPill">sse</span>
<span class="room" id="roomTag" title="room id"></span>
<a href="/" style="margin-left:auto; font-size:11px; color:#888;">+ new session</a>
</header> </header>
<div class="share">
<div class="qr"><img id="qrImg" alt="QR" /></div>
<div class="info">
<small>scan to join this session</small>
<div class="url" id="sessionUrl"></div>
<button id="copyBtn">copy link</button>
</div>
</div>
<div class="peers" id="peerList"></div> <div class="peers" id="peerList"></div>
<div class="row"> <div class="row">
@@ -99,32 +139,41 @@
</div> </div>
<footer> <footer>
<a href="https://gitea.pecord.io/pecord/tether">tether on gitea</a> · v0.4 <a href="https://gitea.pecord.io/pecord/tether">tether on gitea</a> · v0.5
</footer> </footer>
<script> <script>
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const peerID = "browser-" + Array.from(crypto.getRandomValues(new Uint8Array(6))).map(b=>b.toString(16).padStart(2,"0")).join("");
const remotePeers = new Map(); // remoteID -> {pc, dc, lastSeen, status}
const status = (msg, cls) => { // ── Session room ──────────────────────────────────────────────────────
const s = $("status"); // URL is /r/<id>. If somehow we don't have one, redirect home (server will mint).
s.textContent = msg; const roomMatch = window.location.pathname.match(/^\/r\/([a-zA-Z0-9_-]+)/);
s.className = "status" + (cls ? " " + cls : ""); if (!roomMatch) { window.location.replace("/"); }
}; const room = roomMatch ? roomMatch[1] : "";
const setPill = (mode) => { const fullUrl = window.location.origin + "/r/" + room;
const p = $("rtcPill"); const peerID = "browser-" + Array.from(crypto.getRandomValues(new Uint8Array(6))).map(b=>b.toString(16).padStart(2,"0")).join("");
p.textContent = mode;
p.className = "pill " + (mode === "rtc" ? "live" : mode === "negotiating" ? "connecting" : ""); $("roomTag").textContent = room;
}; $("sessionUrl").textContent = fullUrl;
$("qrImg").src = "https://api.qrserver.com/v1/create-qr-code/?size=240x240&margin=0&data=" + encodeURIComponent(fullUrl);
$("copyBtn").addEventListener("click", async () => {
try { await navigator.clipboard.writeText(fullUrl); status("link copied ✓", "ok"); }
catch (e) { status("clipboard denied", "err"); }
});
// ── State ─────────────────────────────────────────────────────────────
const remotePeers = new Map();
const status = (msg, cls) => { const s = $("status"); s.textContent = msg; s.className = "status" + (cls ? " " + cls : ""); };
const setPill = (mode) => { const p = $("rtcPill"); p.textContent = mode; p.className = "pill " + (mode === "rtc" ? "live" : mode === "negotiating" ? "connecting" : ""); };
function refreshPill() { function refreshPill() {
let anyRTC = false; let anyRTC = false, anyConn = false;
let anyConnecting = false;
for (const r of remotePeers.values()) { for (const r of remotePeers.values()) {
if (r.dc && r.dc.readyState === "open") anyRTC = true; if (r.dc && r.dc.readyState === "open") anyRTC = true;
else if (r.status === "connecting" || r.status === "new") anyConnecting = true; else if (r.status === "connecting" || r.status === "new") anyConn = true;
} }
setPill(anyRTC ? "rtc" : (anyConnecting ? "negotiating" : "sse")); setPill(anyRTC ? "rtc" : (anyConn ? "negotiating" : "sse"));
} }
function renderPeers() { function renderPeers() {
const el = $("peerList"); const el = $("peerList");
@@ -139,48 +188,37 @@
function addIncoming(text, source, ts, viaRTC) { function addIncoming(text, source, ts, viaRTC) {
const el = document.createElement("div"); const el = document.createElement("div");
el.className = "msg"; el.className = "msg"; el.textContent = text;
el.textContent = text;
const meta = document.createElement("div"); const meta = document.createElement("div");
meta.className = "meta"; meta.className = "meta";
const badge = viaRTC ? '<span class="rtc-badge">via rtc</span> · ' : ''; const badge = viaRTC ? '<span class="rtc-badge">via rtc</span> · ' : '';
meta.innerHTML = badge + (source || "client") + " @ " + new Date(ts || Date.now()).toLocaleTimeString(); meta.innerHTML = badge + (source || "client") + " @ " + new Date(ts || Date.now()).toLocaleTimeString();
el.appendChild(meta); el.appendChild(meta);
const feed = $("incoming"); $("incoming").insertBefore(el, $("incoming").firstChild);
feed.insertBefore(el, feed.firstChild);
} }
// ── Send ──────────────────────────────────────────────────────────────
$("pasteBtn").addEventListener("click", async () => { $("pasteBtn").addEventListener("click", async () => {
try { try { $("out").value = await navigator.clipboard.readText(); status("pasted from clipboard", "ok"); }
$("out").value = await navigator.clipboard.readText(); catch (e) { status("clipboard read denied — paste manually", "err"); }
status("pasted from clipboard", "ok");
} catch (e) {
status("clipboard read denied — paste manually", "err");
}
}); });
$("sendBtn").addEventListener("click", async () => { $("sendBtn").addEventListener("click", async () => {
const text = $("out").value; const text = $("out").value;
if (!text) { status("empty", "err"); return; } if (!text) { status("empty", "err"); return; }
// Fan out to all open DataChannels first
let rtcSent = 0; let rtcSent = 0;
for (const r of remotePeers.values()) { for (const r of remotePeers.values()) {
if (r.dc && r.dc.readyState === "open") { if (r.dc && r.dc.readyState === "open") {
try { r.dc.send(text); rtcSent++; } catch (e) {} try { r.dc.send(text); rtcSent++; } catch (e) {}
} }
} }
if (rtcSent > 0) { if (rtcSent > 0) { status(`sent via rtc to ${rtcSent} peer(s) ✓`, "ok"); return; }
status(`sent via rtc to ${rtcSent} peer(s) ✓`, "ok");
return;
}
// Fallback to HTTP for anyone not RTC-paired
status("sending via http…"); status("sending via http…");
try { try {
const r = await fetch("/api/send", { const r = await fetch("/api/send?room=" + room, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "clipboard", text, source: "web", from: peerID }), body: JSON.stringify({ type: "clipboard", text, source: "web", from: peerID, room }),
}); });
status(r.ok ? "delivered ✓" : "server returned " + r.status, r.ok ? "ok" : "err"); status(r.ok ? "delivered ✓" : "server returned " + r.status, r.ok ? "ok" : "err");
} catch (e) { status("network error", "err"); } } catch (e) { status("network error", "err"); }
@@ -190,45 +228,30 @@
async function postMessage(m) { async function postMessage(m) {
m.from = m.from || peerID; m.from = m.from || peerID;
m.source = m.source || "web"; m.source = m.source || "web";
await fetch("/api/send", { m.room = room;
await fetch("/api/send?room=" + room, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(m), body: JSON.stringify(m),
}); });
} }
async function postSignal(toID, payload) { async function postSignal(toID, payload) { await postMessage({ type: "signal", to: toID, signal: payload }); }
await postMessage({ type: "signal", to: toID, signal: payload });
}
// We accept incoming offers (we're the answerer). Create a fresh PC per
// remote peer.
async function handleOffer(fromID, sdp) { async function handleOffer(fromID, sdp) {
// If we already have a pc for this peer, close it (re-negotiate).
let r = remotePeers.get(fromID); let r = remotePeers.get(fromID);
if (r && r.pc) { try { r.pc.close(); } catch (_) {} } if (r && r.pc) { try { r.pc.close(); } catch (_) {} }
const pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] });
const pc = new RTCPeerConnection({
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
});
r = { pc, dc: null, lastSeen: Date.now(), status: "new" }; r = { pc, dc: null, lastSeen: Date.now(), status: "new" };
remotePeers.set(fromID, r); remotePeers.set(fromID, r);
refreshPill(); refreshPill(); renderPeers();
renderPeers();
pc.onicecandidate = (ev) => { pc.onicecandidate = (ev) => { if (ev.candidate) postSignal(fromID, { kind: "ice", candidate: ev.candidate.toJSON() }); };
if (ev.candidate) postSignal(fromID, { kind: "ice", candidate: ev.candidate.toJSON() });
};
pc.onconnectionstatechange = () => { pc.onconnectionstatechange = () => {
r.status = pc.connectionState; r.status = pc.connectionState; refreshPill(); renderPeers();
refreshPill(); if (pc.connectionState === "failed" || pc.connectionState === "closed") remotePeers.delete(fromID);
renderPeers();
if (pc.connectionState === "failed" || pc.connectionState === "closed") {
remotePeers.delete(fromID);
}
}; };
pc.ondatachannel = (ev) => { pc.ondatachannel = (ev) => {
const ch = ev.channel; const ch = ev.channel; r.dc = ch;
r.dc = ch;
ch.onopen = () => { refreshPill(); renderPeers(); }; ch.onopen = () => { refreshPill(); renderPeers(); };
ch.onclose = () => { refreshPill(); renderPeers(); }; ch.onclose = () => { refreshPill(); renderPeers(); };
ch.onmessage = (m) => addIncoming(m.data, fromID.slice(0, 12), Date.now(), true); ch.onmessage = (m) => addIncoming(m.data, fromID.slice(0, 12), Date.now(), true);
@@ -242,42 +265,31 @@
async function handleIce(fromID, candidate) { async function handleIce(fromID, candidate) {
const r = remotePeers.get(fromID); const r = remotePeers.get(fromID);
if (r && r.pc) { if (r && r.pc) { try { await r.pc.addIceCandidate(candidate); } catch (e) {} }
try { await r.pc.addIceCandidate(candidate); } catch (e) {}
}
} }
// ── Presence chirp ──────────────────────────────────────────────────── // ── Presence chirp ────────────────────────────────────────────────────
setInterval(() => { setInterval(() => { postMessage({ type: "presence", role: "browser" }).catch(() => {}); }, 10000);
postMessage({ type: "presence", role: "browser" }).catch(() => {});
}, 10000);
// also do one immediately on load
postMessage({ type: "presence", role: "browser" }).catch(() => {}); postMessage({ type: "presence", role: "browser" }).catch(() => {});
// ── SSE main feed ────────────────────────────────────────────────────── // ── SSE main feed ──────────────────────────────────────────────────────
function connectFeed() { function connectFeed() {
const es = new EventSource("/api/stream"); const es = new EventSource("/api/stream?room=" + room);
es.addEventListener("clipboard", (ev) => { es.addEventListener("clipboard", (ev) => {
try { try { const m = JSON.parse(ev.data); if (m.from !== peerID) addIncoming(m.text, m.source, m.ts, false); }
const m = JSON.parse(ev.data); catch (e) {}
if (m.from !== peerID) addIncoming(m.text, m.source, m.ts, false);
} catch (e) {}
}); });
es.addEventListener("signal", (ev) => { es.addEventListener("signal", (ev) => {
try { try {
const m = JSON.parse(ev.data); const m = JSON.parse(ev.data);
if (m.from === peerID) return; if (m.from === peerID) return;
if (m.to && m.to !== peerID) return; // not for us if (m.to && m.to !== peerID) return;
const payload = m.signal; const p = m.signal;
if (!payload) return; if (!p) return;
if (payload.kind === "offer" && payload.sdp) handleOffer(m.from, payload.sdp.sdp); if (p.kind === "offer" && p.sdp) handleOffer(m.from, p.sdp.sdp);
else if (payload.kind === "ice" && payload.candidate) handleIce(m.from, payload.candidate); else if (p.kind === "ice" && p.candidate) handleIce(m.from, p.candidate);
} catch (e) {} } catch (e) {}
}); });
es.addEventListener("presence", (ev) => {
// Browsers just note presence; peers (Go side) act on it.
// We could render a "peer online" indicator here later.
});
es.onerror = () => setTimeout(connectFeed, 2000); es.onerror = () => setTimeout(connectFeed, 2000);
} }
connectFeed(); connectFeed();