1 Commits

Author SHA1 Message Date
Ryan Hamamura
729db559ea feat: add connection status indicator with SSE heartbeat
All checks were successful
CI / Deploy / test (pull_request) Successful in 17s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped
- Add ConnectionIndicator component showing green/red dot
- Send lastPing signal every 15 seconds via SSE
- Indicator turns red if no ping received in 20 seconds
- Gives users confidence the live connection is active
2026-03-03 09:53:20 -10:00
3 changed files with 31 additions and 37 deletions

View File

@@ -76,11 +76,12 @@ func (r *Room) Send(msg Message) {
}
}
// receive processes an incoming NATS message, appending it to the buffer.
func (r *Room) receive(data []byte) (Message, bool) {
// Receive processes an incoming NATS message, appending it to the buffer.
// Returns the new message and a snapshot of all messages.
func (r *Room) Receive(data []byte) (Message, []Message) {
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
return msg, false
return msg, nil
}
r.mu.Lock()
@@ -88,9 +89,11 @@ func (r *Room) receive(data []byte) (Message, bool) {
if len(r.messages) > maxMessages {
r.messages = r.messages[len(r.messages)-maxMessages:]
}
snapshot := make([]Message, len(r.messages))
copy(snapshot, r.messages)
r.mu.Unlock()
return msg, true
return msg, snapshot
}
// Messages returns a snapshot of the current message buffer.
@@ -102,32 +105,15 @@ func (r *Room) Messages() []Message {
return snapshot
}
// Subscribe returns a channel of parsed messages and a cleanup function.
// The room handles NATS subscription internally and buffers messages.
func (r *Room) Subscribe() (<-chan Message, func()) {
natsCh := make(chan *nats.Msg, 64)
msgCh := make(chan Message, 64)
sub, err := r.nc.ChanSubscribe(r.subject, natsCh)
// Subscribe creates a NATS channel subscription for the room's subject.
// Caller is responsible for unsubscribing.
func (r *Room) Subscribe() (chan *nats.Msg, *nats.Subscription, error) {
ch := make(chan *nats.Msg, 64)
sub, err := r.nc.ChanSubscribe(r.subject, ch)
if err != nil {
close(msgCh)
return msgCh, func() {}
return nil, nil, err
}
go func() {
for natsMsg := range natsCh {
if msg, ok := r.receive(natsMsg.Data); ok {
msgCh <- msg
}
}
close(msgCh)
}()
cleanup := func() {
_ = sub.Unsubscribe()
}
return msgCh, cleanup
return ch, sub, nil
}
func (r *Room) saveMessage(msg Message) {

View File

@@ -142,8 +142,11 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag
defer gameSub.Unsubscribe() //nolint:errcheck
// Subscribe to chat messages
chatCh, cleanupChat := room.Subscribe()
defer cleanupChat()
chatCh, chatSub, err := room.Subscribe()
if err != nil {
return
}
defer chatSub.Unsubscribe() //nolint:errcheck
ctx := r.Context()
for {
@@ -158,7 +161,8 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag
if err := patchAll(); err != nil {
return
}
case chatMsg := <-chatCh:
case msg := <-chatCh:
chatMsg, _ := room.Receive(msg.Data)
err := sse.PatchElementTempl(
chatcomponents.ChatMessage(chatMsg, chatCfg),
datastar.WithSelectorID("c4-chat-history"),

View File

@@ -148,12 +148,15 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
defer gameSub.Unsubscribe() //nolint:errcheck
// Chat subscription (multiplayer only)
var chatCh <-chan chat.Message
var cleanupChat func()
var chatCh chan *nats.Msg
var chatSub *nats.Subscription
if room != nil {
chatCh, cleanupChat = room.Subscribe()
defer cleanupChat()
chatCh, chatSub, err = room.Subscribe()
if err != nil {
return
}
defer chatSub.Unsubscribe() //nolint:errcheck
}
ctx := r.Context()
@@ -181,10 +184,11 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
return
}
case chatMsg, ok := <-chatCh:
if !ok {
case msg := <-chatCh:
if msg == nil {
continue
}
chatMsg, _ := room.Receive(msg.Data)
err := sse.PatchElementTempl(
chatcomponents.ChatMessage(chatMsg, chatCfg),
datastar.WithSelectorID("snake-chat-history"),