mirror of
https://github.com/therootcompany/golib.git
synced 2026-02-10 05:48:06 +00:00
feat: add cmd/ssechat as ServerSentEvents demo
This commit is contained in:
parent
ef78d2fa05
commit
c1ba5b4744
3
cmd/ssechat/go.mod
Normal file
3
cmd/ssechat/go.mod
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
module github.com/therootcompany/golib/cmd/ssechat
|
||||||
|
|
||||||
|
go 1.25.4
|
||||||
154
cmd/ssechat/index.html
Normal file
154
cmd/ssechat/index.html
Normal 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
118
cmd/ssechat/main.go
Normal 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
92
cmd/ssechat/sse.go
Normal 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)
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user