feat: add cmd/ssechat as ServerSentEvents demo

This commit is contained in:
AJ ONeal 2026-02-06 00:32:20 -07:00
parent ef78d2fa05
commit c1ba5b4744
No known key found for this signature in database
4 changed files with 367 additions and 0 deletions

3
cmd/ssechat/go.mod Normal file
View File

@ -0,0 +1,3 @@
module github.com/therootcompany/golib/cmd/ssechat
go 1.25.4

154
cmd/ssechat/index.html Normal file
View File

@ -0,0 +1,154 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SSE Chat Room</title>
<style>
body {
font-family: system-ui, sans-serif;
margin: 0;
padding: 1rem;
background: #f8f9fa;
color: #111;
}
header {
text-align: center;
margin-bottom: 1.5rem;
}
#messages {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
height: 60vh;
overflow-y: auto;
font-family: monospace;
white-space: pre-wrap;
margin-bottom: 1rem;
}
form {
display: flex;
gap: 0.5rem;
max-width: 600px;
margin: 0 auto;
}
input[type="text"] {
flex: 1;
padding: 0.8rem;
border: 1px solid #ccc;
border-radius: 6px;
font-size: 1rem;
}
button {
padding: 0.8rem 1.5rem;
background: #0066cc;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
}
button:hover {
background: #0055aa;
}
.msg {
margin: 0.4rem 0;
}
.msg.system {
color: #666;
font-style: italic;
}
.msg.user {
color: #0066cc;
}
</style>
</head>
<body>
<header>
<h1>Simple SSE Chat Room</h1>
<p>Everyone sees the same messages in real time</p>
</header>
<div id="messages"></div>
<form id="chat-form">
<input
type="text"
id="nickname"
placeholder="Your name"
value="Guest"
style="width: 140px"
/>
<input
type="text"
id="message"
placeholder="Type a message..."
autocomplete="off"
required
/>
<button type="submit">Send</button>
</form>
<script>
const messagesDiv = document.getElementById("messages");
const form = document.getElementById("chat-form");
const msgInput = document.getElementById("message");
const nickInput = document.getElementById("nickname");
// Connect to SSE
const evtSource = new EventSource("/api/events");
evtSource.onmessage = (event) => {
const data = JSON.parse(event.data);
appendMessage(`${data.time} • ${data.nick}: ${data.text}`);
};
evtSource.addEventListener("system", (event) => {
const data = JSON.parse(event.data);
appendMessage(`${data.time} • ${data.text}`, "system");
});
evtSource.onerror = () => {
appendMessage("[Connection lost — reconnecting...]", "system");
};
function appendMessage(text, kind = "user") {
const line = document.createElement("div");
line.className = `msg ${kind}`;
line.textContent = text;
messagesDiv.appendChild(line);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
// Send message on form submit
form.addEventListener("submit", async (e) => {
e.preventDefault();
const text = msgInput.value.trim();
if (!text) return;
const nick = nickInput.value.trim() || "Anonymous";
try {
await fetch("/api/send", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({ nick, text }),
});
msgInput.value = "";
msgInput.focus();
} catch (err) {
appendMessage("[Failed to send message]", "system");
}
});
// Initial welcome
appendMessage(
"Welcome to the chat! Type below to join the conversation.",
"system"
);
</script>
</body>
</html>

118
cmd/ssechat/main.go Normal file
View File

@ -0,0 +1,118 @@
package main
import (
_ "embed"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
)
//go:embed index.html
var indexHTML []byte
type Message struct {
Time string `json:"time"`
Nick string `json:"nick"`
Text string `json:"text"`
}
var sse = NewSSEChannel()
func main() {
mux := http.NewServeMux()
// Serve static HTML
//mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write(indexHTML)
})
// SSE endpoint
mux.HandleFunc("GET /api/events", handleSSE)
// POST endpoint to send messages
mux.HandleFunc("POST /api/send", handleSend)
log.Println("Server running on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
func handleSSE(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // helps nginx / some proxies
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
return
}
sse.Subscribe(AddrStr(r.RemoteAddr))
defer sse.Unsubscribe(AddrStr(r.RemoteAddr))
fmt.Fprintf(w, "event: system\ndata: {\"time\":\"%s\",\"text\":\"You joined the room\"}\n\n", time.Now().Format("15:04"))
flusher.Flush()
// Forward messages to this client
for {
select {
case <-r.Context().Done():
return
case msg, ok := <-sse.Member(AddrStr(r.RemoteAddr)).C:
if !ok {
return
}
// SSE format: one data: line with the full JSON
if msg.Event != "" {
fmt.Fprintf(w, "event: %s\n", msg.Event)
}
fmt.Fprintf(w, "%s: %s\n\n", msg.Type, msg.Data)
flusher.Flush()
}
}
}
func handleSend(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad form", http.StatusBadRequest)
return
}
nick := r.FormValue("nick")
text := r.FormValue("text")
if text == "" {
http.Error(w, "Message required", http.StatusBadRequest)
return
}
if nick == "" {
nick = "Anonymous"
}
// In the broadcast loop (handleSend)
msg := Message{
Time: time.Now().Format("15:04"),
Nick: nick,
Text: text,
}
payload, _ := json.Marshal(msg)
sse.Broadcast(payload)
w.WriteHeader(http.StatusAccepted)
}

92
cmd/ssechat/sse.go Normal file
View File

@ -0,0 +1,92 @@
package main
import (
"fmt"
"sync"
"time"
)
type AddrStr string
type ServerSentEvent struct {
Type string
Event string
ID string
Data []byte
}
type SSEMember struct {
C chan ServerSentEvent
ticker *time.Ticker
}
type SSEChannel struct {
clients map[AddrStr]SSEMember
clientsMu sync.Mutex
}
func NewSSEChannel() *SSEChannel {
return &SSEChannel{
clients: make(map[AddrStr]SSEMember),
}
}
func (c *SSEChannel) Subscribe(addr AddrStr) {
ch := make(chan ServerSentEvent, 16)
m := SSEMember{
ch,
time.NewTicker(15 * time.Second),
}
c.clientsMu.Lock()
c.clients[addr] = m
c.clientsMu.Unlock()
go func() {
for range m.ticker.C {
m.C <- ServerSentEvent{
"",
"",
"",
[]byte("heartbeat"),
}
}
}()
}
func (c *SSEChannel) Member(addr AddrStr) SSEMember {
c.clientsMu.Lock()
defer c.clientsMu.Unlock()
m := c.clients[addr]
return m
}
func (c *SSEChannel) Broadcast(payload []byte) {
c.clientsMu.Lock()
defer c.clientsMu.Unlock()
for _, m := range c.clients {
// drop-on-full (best effort delivery)
select {
case m.C <- ServerSentEvent{
Type: "data",
Event: "message",
ID: fmt.Sprintf("%d", time.Now().UnixMilli()), // or use atomic counter
Data: payload,
}:
// client was able to receive
default:
// client is backed up
}
}
}
func (c *SSEChannel) Unsubscribe(addr AddrStr) {
c.clientsMu.Lock()
defer c.clientsMu.Unlock()
m := c.clients[addr]
delete(c.clients, addr)
// ticker must be stopped BEFORE closing channel
m.ticker.Stop()
close(m.C)
}