Files
verdande/internal/httpapi/realtime_handlers.go
T
Kristian WindandClaude Opus 5 f15c440942 feat(realtime): keep projects and labels current too, not only tasks
The socket carried task events and nothing else, so a project or a label
created anywhere — another tab, another device, Claude through the
connector — did not appear until a reload. Tasks updated live and the
sidebar did not, which reads as the sidebar being broken rather than as a
gap in what is published.

Projects and labels go to the *user* rather than to a project. A project
that has just been created has no subscribers, because nobody was
watching a thing that did not exist a moment ago.

Labels are announced as "something changed" and re-read, rather than sent
as a payload. The sidebar wants each label's task count, which no single
event carries and which moves whenever a task gains or loses a label —
including through quick add, where "@regnskab" invents the label inside a
task write and no label event is sent at all. Comparing the label lists
on a task change is what catches that, without re-reading on every ticked
checkbox.

And projects now go through an upsert, because the same row arrives twice
— once as the response to the request that created it, once over the
socket. Appending both puts two ids into a keyed {#each}, which throws
and stops the sidebar rendering. The tasks already had that guard; adding
the project event without it reintroduced the identical bug, and the
smoke test caught it on the first run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:27:18 +02:00

146 lines
4.3 KiB
Go

package httpapi
import (
"context"
"net/http"
"time"
"github.com/coder/websocket"
"github.com/kristianwind/verdande/internal/realtime"
)
// activity records what happened, and never fails the request that caused it.
// A history that is missing an entry is a smaller problem than a rename that was
// refused because its history entry could not be written.
func (s *Server) activity(r *http.Request, projectID, taskID, event string, payload map[string]any) {
user := userFrom(r.Context())
if user == nil || projectID == "" {
return
}
if err := s.db.RecordActivity(r.Context(), projectID, taskID, user.ID, event, payload); err != nil {
s.log.Warn("record activity", "err", err, "event", event)
}
}
// publish pushes a change to everyone else looking at the project.
func (s *Server) publish(projectID, event string, payload any) {
s.hub.Publish(projectID, event, payload)
}
// publishToOwner sends a change that belongs to a person rather than to a
// project: their projects, their labels, their saved filters.
//
// Project membership is what the socket subscribes to, and a project that has
// just been created has no subscribers — the person is not watching a thing that
// did not exist a moment ago. So these go to the user instead, which also
// reaches their other devices.
func (s *Server) publishToOwner(r *http.Request, event string, payload any) {
if user := userFrom(r.Context()); user != nil {
s.hub.PublishToUser(user.ID, event, payload)
}
}
// handleWebSocket is the live-sync connection.
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// The socket is same-origin only. Without this, any page on the internet
// could open a socket carrying the user's cookie and watch their tasks —
// the same-origin policy does not apply to WebSocket handshakes.
OriginPatterns: s.allowedOrigins(),
})
if err != nil {
s.log.Debug("websocket handshake", "err", err)
return
}
defer conn.CloseNow()
projects, err := s.db.ListProjects(r.Context(), user.ID, true)
if err != nil {
conn.Close(websocket.StatusInternalError, "could not load projects")
return
}
ids := make([]string, 0, len(projects))
for _, p := range projects {
ids = append(ids, p.ID)
}
client := s.hub.Register(user.ID, ids)
defer s.hub.Unregister(client)
// Detached from the request context: r.Context() is cancelled when the handler
// returns, and this connection has to outlive that.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Reading is what notices a closed connection, and it is also what keeps the
// pings flowing. Nothing a client sends is acted on — this stream is one-way
// by design, so there is no client message that can change server state.
go func() {
defer cancel()
for {
if _, _, err := conn.Read(ctx); err != nil {
return
}
}
}()
// A ping every 30 seconds keeps the connection alive through the idle timeouts
// of a reverse proxy — Cloudflare Tunnel closes a silent socket after 100
// seconds, which would otherwise look like the app randomly losing sync.
ping := time.NewTicker(30 * time.Second)
defer ping.Stop()
for {
select {
case <-ctx.Done():
return
case event, ok := <-client.Events():
if !ok {
conn.Close(websocket.StatusNormalClosure, "")
return
}
raw, err := realtime.Encode(event)
if err != nil {
continue
}
writeCtx, writeCancel := context.WithTimeout(ctx, 10*time.Second)
err = conn.Write(writeCtx, websocket.MessageText, raw)
writeCancel()
if err != nil {
return
}
case <-ping.C:
pingCtx, pingCancel := context.WithTimeout(ctx, 10*time.Second)
err := conn.Ping(pingCtx)
pingCancel()
if err != nil {
return
}
}
}
}
// allowedOrigins is the instance's own address, plus localhost when developing so
// the Vite dev server can connect.
func (s *Server) allowedOrigins() []string {
origins := []string{stripScheme(s.cfg.BaseURL)}
if s.cfg.Dev {
origins = append(origins, "localhost:*", "127.0.0.1:*")
}
return origins
}
func stripScheme(url string) string {
for _, prefix := range []string{"https://", "http://"} {
if len(url) > len(prefix) && url[:len(prefix)] == prefix {
return url[len(prefix):]
}
}
return url
}