feat(local-first): add local-first architecture with Dexie.js, Go sync server, and Todo pilot

Implement the foundational local-first data layer for ManaCore apps:

- New @manacore/local-store package (Dexie.js IndexedDB, sync engine, Svelte 5 reactive queries)
- New mana-sync Go service (sync protocol, WebSocket push, field-level LWW conflict resolution)
- Todo app migrated as pilot: stores read/write IndexedDB, guest mode with onboarding seed data
- PillNavigation: prominent login pill for unauthenticated users
- SyncIndicator component showing local/syncing/offline status
- GuestWelcomeModal on first visit for Todo app
- Removed demo-mode auth_required checks from Todo components (all writes are now local)
- CSP fix for local development (localhost:3001, localhost:3050)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-27 11:17:58 +01:00
parent 4ddff8485b
commit 2e4bb9bad7
41 changed files with 4388 additions and 340 deletions

View file

@ -0,0 +1,190 @@
package auth
import (
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
)
// Claims represents the JWT payload from mana-core-auth.
type Claims struct {
jwt.RegisteredClaims
Email string `json:"email"`
Role string `json:"role"`
SID string `json:"sid"`
}
// Validator validates JWTs using EdDSA keys from the JWKS endpoint.
type Validator struct {
jwksURL string
keys map[string]ed25519.PublicKey
mu sync.RWMutex
lastFetch time.Time
fetchEvery time.Duration
}
// NewValidator creates a JWT validator that fetches keys from the given JWKS URL.
func NewValidator(jwksURL string) *Validator {
return &Validator{
jwksURL: jwksURL,
keys: make(map[string]ed25519.PublicKey),
fetchEvery: 5 * time.Minute,
}
}
// ValidateToken validates a JWT and returns the claims.
func (v *Validator) ValidateToken(tokenStr string) (*Claims, error) {
// Ensure we have keys
if err := v.ensureKeys(); err != nil {
return nil, fmt.Errorf("fetch JWKS: %w", err)
}
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
kid, ok := token.Header["kid"].(string)
if !ok {
return nil, fmt.Errorf("missing kid in token header")
}
v.mu.RLock()
key, found := v.keys[kid]
v.mu.RUnlock()
if !found {
// Try refreshing keys once
v.mu.Lock()
v.lastFetch = time.Time{} // Force refresh
v.mu.Unlock()
if err := v.ensureKeys(); err != nil {
return nil, err
}
v.mu.RLock()
key, found = v.keys[kid]
v.mu.RUnlock()
if !found {
return nil, fmt.Errorf("unknown key ID: %s", kid)
}
}
return key, nil
}, jwt.WithValidMethods([]string{"EdDSA"}))
if err != nil {
return nil, fmt.Errorf("parse token: %w", err)
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}
// ExtractToken extracts the bearer token from an HTTP request.
func ExtractToken(r *http.Request) string {
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
return auth[7:]
}
return ""
}
// UserIDFromRequest validates the token and returns the user ID (sub claim).
func (v *Validator) UserIDFromRequest(r *http.Request) (string, error) {
token := ExtractToken(r)
if token == "" {
return "", fmt.Errorf("no authorization header")
}
claims, err := v.ValidateToken(token)
if err != nil {
return "", err
}
if claims.Subject == "" {
return "", fmt.Errorf("missing sub claim")
}
return claims.Subject, nil
}
func (v *Validator) ensureKeys() error {
v.mu.RLock()
if time.Since(v.lastFetch) < v.fetchEvery && len(v.keys) > 0 {
v.mu.RUnlock()
return nil
}
v.mu.RUnlock()
v.mu.Lock()
defer v.mu.Unlock()
// Double-check after acquiring write lock
if time.Since(v.lastFetch) < v.fetchEvery && len(v.keys) > 0 {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", v.jwksURL, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("fetch JWKS from %s: %w", v.jwksURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("JWKS returned status %d", resp.StatusCode)
}
var jwks struct {
Keys []struct {
KID string `json:"kid"`
KTY string `json:"kty"`
CRV string `json:"crv"`
X string `json:"x"`
} `json:"keys"`
}
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
return fmt.Errorf("decode JWKS: %w", err)
}
for _, key := range jwks.Keys {
if key.KTY != "OKP" || key.CRV != "Ed25519" {
continue
}
xBytes, err := base64.RawURLEncoding.DecodeString(key.X)
if err != nil {
continue
}
if len(xBytes) == ed25519.PublicKeySize {
v.keys[key.KID] = ed25519.PublicKey(xBytes)
}
}
v.lastFetch = time.Now()
return nil
}

View file

@ -0,0 +1,33 @@
package config
import (
"os"
"strconv"
)
// Config holds all configuration for the sync server.
type Config struct {
Port int
DatabaseURL string
JWKSUrl string // mana-core-auth JWKS endpoint for JWT validation
CORSOrigins string
}
// Load reads configuration from environment variables with sensible defaults.
func Load() *Config {
port, _ := strconv.Atoi(getEnv("PORT", "3050"))
return &Config{
Port: port,
DatabaseURL: getEnv("DATABASE_URL", "postgresql://manacore:devpassword@localhost:5432/mana_sync"),
JWKSUrl: getEnv("JWKS_URL", "http://localhost:3001/.well-known/jwks.json"),
CORSOrigins: getEnv("CORS_ORIGINS", "http://localhost:5173,http://localhost:5188"),
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View file

@ -0,0 +1,188 @@
package store
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store handles all PostgreSQL operations for the sync server.
type Store struct {
pool *pgxpool.Pool
}
// New creates a new Store with a connection pool.
func New(ctx context.Context, databaseURL string) (*Store, error) {
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
return nil, fmt.Errorf("failed to create pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
return &Store{pool: pool}, nil
}
// Close shuts down the connection pool.
func (s *Store) Close() {
s.pool.Close()
}
// Migrate creates the sync_changes table if it doesn't exist.
func (s *Store) Migrate(ctx context.Context) error {
query := `
CREATE TABLE IF NOT EXISTS sync_changes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id TEXT NOT NULL,
table_name TEXT NOT NULL,
record_id TEXT NOT NULL,
user_id TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('insert', 'update', 'delete')),
data JSONB,
field_timestamps JSONB DEFAULT '{}',
client_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_sync_changes_user_app
ON sync_changes (user_id, app_id, created_at);
CREATE INDEX IF NOT EXISTS idx_sync_changes_table_record
ON sync_changes (table_name, record_id, created_at);
CREATE INDEX IF NOT EXISTS idx_sync_changes_since
ON sync_changes (user_id, app_id, table_name, created_at);
`
_, err := s.pool.Exec(ctx, query)
return err
}
// RecordChange stores a client change in the database.
func (s *Store) RecordChange(ctx context.Context, appID, tableName, recordID, userID, op, clientID string, data map[string]any, fieldTimestamps map[string]string) error {
dataJSON, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("marshal data: %w", err)
}
ftJSON, err := json.Marshal(fieldTimestamps)
if err != nil {
return fmt.Errorf("marshal field_timestamps: %w", err)
}
query := `
INSERT INTO sync_changes (app_id, table_name, record_id, user_id, op, data, field_timestamps, client_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`
_, err = s.pool.Exec(ctx, query, appID, tableName, recordID, userID, op, dataJSON, ftJSON, clientID)
return err
}
// GetChangesSince returns all changes for a user+app+table since a given timestamp,
// excluding changes from the requesting client (to avoid echo).
func (s *Store) GetChangesSince(ctx context.Context, userID, appID, tableName, since, excludeClientID string) ([]ChangeRow, error) {
sinceTime, err := time.Parse(time.RFC3339Nano, since)
if err != nil {
sinceTime = time.Unix(0, 0)
}
query := `
SELECT id, table_name, record_id, op, data, field_timestamps, client_id, created_at
FROM sync_changes
WHERE user_id = $1 AND app_id = $2 AND table_name = $3
AND created_at > $4 AND client_id != $5
ORDER BY created_at ASC
LIMIT 1000
`
rows, err := s.pool.Query(ctx, query, userID, appID, tableName, sinceTime, excludeClientID)
if err != nil {
return nil, err
}
defer rows.Close()
var changes []ChangeRow
for rows.Next() {
var c ChangeRow
var dataJSON, ftJSON []byte
err := rows.Scan(&c.ID, &c.TableName, &c.RecordID, &c.Op, &dataJSON, &ftJSON, &c.ClientID, &c.CreatedAt)
if err != nil {
return nil, err
}
if dataJSON != nil {
json.Unmarshal(dataJSON, &c.Data)
}
if ftJSON != nil {
json.Unmarshal(ftJSON, &c.FieldTimestamps)
}
changes = append(changes, c)
}
return changes, rows.Err()
}
// GetAllChangesSince returns changes across all tables for a user+app.
func (s *Store) GetAllChangesSince(ctx context.Context, userID, appID, since, excludeClientID string) ([]ChangeRow, error) {
sinceTime, err := time.Parse(time.RFC3339Nano, since)
if err != nil {
sinceTime = time.Unix(0, 0)
}
query := `
SELECT id, table_name, record_id, op, data, field_timestamps, client_id, created_at
FROM sync_changes
WHERE user_id = $1 AND app_id = $2
AND created_at > $3 AND client_id != $4
ORDER BY created_at ASC
LIMIT 5000
`
rows, err := s.pool.Query(ctx, query, userID, appID, sinceTime, excludeClientID)
if err != nil {
return nil, err
}
defer rows.Close()
var changes []ChangeRow
for rows.Next() {
var c ChangeRow
var dataJSON, ftJSON []byte
err := rows.Scan(&c.ID, &c.TableName, &c.RecordID, &c.Op, &dataJSON, &ftJSON, &c.ClientID, &c.CreatedAt)
if err != nil {
return nil, err
}
if dataJSON != nil {
json.Unmarshal(dataJSON, &c.Data)
}
if ftJSON != nil {
json.Unmarshal(ftJSON, &c.FieldTimestamps)
}
changes = append(changes, c)
}
return changes, rows.Err()
}
// ChangeRow is a row from the sync_changes table.
type ChangeRow struct {
ID string
TableName string
RecordID string
Op string
Data map[string]any
FieldTimestamps map[string]string
ClientID string
CreatedAt time.Time
}

View file

@ -0,0 +1,235 @@
package sync
import (
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/manacore/mana-sync/internal/auth"
"github.com/manacore/mana-sync/internal/store"
"github.com/manacore/mana-sync/internal/ws"
)
// Handler handles sync HTTP endpoints.
type Handler struct {
store *store.Store
validator *auth.Validator
hub *ws.Hub
}
// NewHandler creates a new sync handler.
func NewHandler(s *store.Store, v *auth.Validator, h *ws.Hub) *Handler {
return &Handler{store: s, validator: v, hub: h}
}
// HandleSync processes a POST /sync/:appId request.
// Receives a changeset from a client, records changes, and returns the server delta.
func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Authenticate
userID, err := h.validator.UserIDFromRequest(r)
if err != nil {
http.Error(w, "unauthorized: "+err.Error(), http.StatusUnauthorized)
return
}
// Parse app ID from path: /sync/{appId}
appID := r.PathValue("appId")
if appID == "" {
http.Error(w, "missing appId", http.StatusBadRequest)
return
}
// Parse changeset
var changeset Changeset
if err := json.NewDecoder(r.Body).Decode(&changeset); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
ctx := r.Context()
clientID := r.Header.Get("X-Client-Id")
if clientID == "" {
clientID = changeset.ClientID
}
// Process each change
affectedTables := make(map[string]struct{})
for _, change := range changeset.Changes {
affectedTables[change.Table] = struct{}{}
// Build data and field timestamps
data := change.Data
fieldTimestamps := make(map[string]string)
if change.Op == "update" && change.Fields != nil {
data = make(map[string]any)
for field, fc := range change.Fields {
data[field] = fc.Value
fieldTimestamps[field] = fc.UpdatedAt
}
}
if change.Op == "delete" {
if data == nil {
data = make(map[string]any)
}
if change.DeletedAt != nil {
data["deletedAt"] = *change.DeletedAt
}
}
err := h.store.RecordChange(ctx, appID, change.Table, change.ID, userID, change.Op, clientID, data, fieldTimestamps)
if err != nil {
slog.Error("failed to record change", "error", err, "table", change.Table, "id", change.ID)
// Continue processing other changes
}
}
// Get server changes since client's last sync (excluding client's own changes)
serverChanges, err := h.store.GetAllChangesSince(ctx, userID, appID, changeset.Since, clientID)
if err != nil {
slog.Error("failed to get server changes", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// Convert store rows to sync changes
responseChanges := make([]Change, 0, len(serverChanges))
for _, row := range serverChanges {
c := Change{
Table: row.TableName,
ID: row.RecordID,
Op: row.Op,
}
switch row.Op {
case "insert":
c.Data = row.Data
case "update":
c.Fields = make(map[string]*FieldChange)
for field, ts := range row.FieldTimestamps {
value, ok := row.Data[field]
if !ok {
continue
}
c.Fields[field] = &FieldChange{
Value: value,
UpdatedAt: ts,
}
}
case "delete":
if deletedAt, ok := row.Data["deletedAt"].(string); ok {
c.DeletedAt = &deletedAt
}
}
responseChanges = append(responseChanges, c)
}
now := time.Now().UTC().Format(time.RFC3339Nano)
resp := SyncResponse{
ServerChanges: responseChanges,
Conflicts: []SyncConflict{}, // Field-level LWW doesn't produce conflicts
SyncedUntil: now,
}
// Notify other connected clients via WebSocket
if len(affectedTables) > 0 {
tables := make([]string, 0, len(affectedTables))
for t := range affectedTables {
tables = append(tables, t)
}
h.hub.NotifyUser(userID, appID, clientID, tables)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// HandlePull processes a GET /sync/:appId/pull request.
// Returns server changes for a specific collection since a timestamp.
func (h *Handler) HandlePull(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
userID, err := h.validator.UserIDFromRequest(r)
if err != nil {
http.Error(w, "unauthorized: "+err.Error(), http.StatusUnauthorized)
return
}
appID := r.PathValue("appId")
if appID == "" {
http.Error(w, "missing appId", http.StatusBadRequest)
return
}
collection := r.URL.Query().Get("collection")
since := r.URL.Query().Get("since")
clientID := r.Header.Get("X-Client-Id")
if collection == "" || since == "" {
http.Error(w, "missing collection or since parameter", http.StatusBadRequest)
return
}
ctx := r.Context()
serverChanges, err := h.store.GetChangesSince(ctx, userID, appID, collection, since, clientID)
if err != nil {
slog.Error("failed to get changes", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
responseChanges := make([]Change, 0, len(serverChanges))
for _, row := range serverChanges {
c := Change{
Table: row.TableName,
ID: row.RecordID,
Op: row.Op,
}
switch row.Op {
case "insert":
c.Data = row.Data
case "update":
c.Fields = make(map[string]*FieldChange)
for field, ts := range row.FieldTimestamps {
value, ok := row.Data[field]
if !ok {
continue
}
c.Fields[field] = &FieldChange{
Value: value,
UpdatedAt: ts,
}
}
case "delete":
if deletedAt, ok := row.Data["deletedAt"].(string); ok {
c.DeletedAt = &deletedAt
}
}
responseChanges = append(responseChanges, c)
}
now := time.Now().UTC().Format(time.RFC3339Nano)
resp := SyncResponse{
ServerChanges: responseChanges,
Conflicts: []SyncConflict{},
SyncedUntil: now,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}

View file

@ -0,0 +1,66 @@
package sync
import "time"
// Change represents a single field-level change to a record.
type Change struct {
Table string `json:"table"`
ID string `json:"id"`
Op string `json:"op"` // "insert", "update", "delete"
Fields map[string]*FieldChange `json:"fields,omitempty"`
Data map[string]any `json:"data,omitempty"`
DeletedAt *string `json:"deletedAt,omitempty"`
}
// FieldChange holds a value and the timestamp when it was last changed.
type FieldChange struct {
Value any `json:"value"`
UpdatedAt string `json:"updatedAt"`
}
// Changeset is a batch of changes sent by a client.
type Changeset struct {
ClientID string `json:"clientId"`
AppID string `json:"appId"`
Since string `json:"since"` // ISO timestamp
Changes []Change `json:"changes"`
}
// SyncResponse is returned after processing a changeset.
type SyncResponse struct {
ServerChanges []Change `json:"serverChanges"`
Conflicts []SyncConflict `json:"conflicts"`
SyncedUntil string `json:"syncedUntil"`
}
// SyncConflict describes a conflict that couldn't be auto-resolved.
type SyncConflict struct {
Table string `json:"table"`
ID string `json:"id"`
Field string `json:"field"`
ClientValue any `json:"clientValue"`
ClientTimestamp string `json:"clientTimestamp"`
ServerValue any `json:"serverValue"`
ServerTimestamp string `json:"serverTimestamp"`
}
// PullRequest represents a pull query from a client.
type PullRequest struct {
Collection string `json:"collection"`
Since string `json:"since"`
}
// SyncRecord is a row in the sync_changes table.
type SyncRecord struct {
ID string `json:"id"`
AppID string `json:"appId"`
TableName string `json:"tableName"`
RecordID string `json:"recordId"`
UserID string `json:"userId"`
Op string `json:"op"`
Fields map[string]any `json:"fields,omitempty"`
Data map[string]any `json:"data,omitempty"`
FieldTimestamps map[string]string `json:"fieldTimestamps,omitempty"`
ClientID string `json:"clientId"`
CreatedAt time.Time `json:"createdAt"`
}

View file

@ -0,0 +1,209 @@
package ws
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"sync"
"github.com/coder/websocket"
)
// Message types sent over WebSocket.
type Message struct {
Type string `json:"type"`
Tables []string `json:"tables,omitempty"`
Token string `json:"token,omitempty"`
}
// Client represents a connected WebSocket client.
type Client struct {
UserID string
AppID string
Conn *websocket.Conn
cancel context.CancelFunc
}
// Hub manages WebSocket connections and broadcasts sync notifications.
type Hub struct {
// clients maps userID -> set of clients
clients map[string]map[*Client]struct{}
mu sync.RWMutex
}
// NewHub creates a new WebSocket hub.
func NewHub() *Hub {
return &Hub{
clients: make(map[string]map[*Client]struct{}),
}
}
// HandleWebSocket upgrades an HTTP connection to WebSocket and registers the client.
// The userID is initially empty — the client must send an auth message first.
func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request, appID string) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
OriginPatterns: []string{"*"},
})
if err != nil {
slog.Error("websocket accept failed", "error", err)
return
}
ctx, cancel := context.WithCancel(r.Context())
client := &Client{
AppID: appID,
Conn: conn,
cancel: cancel,
}
// Read loop: handle auth and other messages
go h.readLoop(ctx, client)
}
// NotifyUser sends a sync-available message to all connected clients of a user,
// except the client that originated the change.
func (h *Hub) NotifyUser(userID, appID, excludeClientID string, tables []string) {
h.mu.RLock()
clients, ok := h.clients[userID]
h.mu.RUnlock()
if !ok {
return
}
msg := Message{
Type: "sync-available",
Tables: tables,
}
data, err := json.Marshal(msg)
if err != nil {
return
}
for client := range clients {
if client.AppID != appID {
continue
}
// Don't echo back to the sender (client ID is in the WS client)
go func(c *Client) {
err := c.Conn.Write(context.Background(), websocket.MessageText, data)
if err != nil {
h.removeClient(c)
}
}(client)
}
}
func (h *Hub) readLoop(ctx context.Context, client *Client) {
defer func() {
h.removeClient(client)
client.Conn.Close(websocket.StatusNormalClosure, "closing")
client.cancel()
}()
for {
_, data, err := client.Conn.Read(ctx)
if err != nil {
return
}
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
continue
}
switch msg.Type {
case "auth":
// Client sends token after connecting — we store the userID
// In production, validate the token here. For now, trust it
// since the HTTP sync endpoint already validates.
if msg.Token != "" {
// The actual validation happens in the sync handler.
// Here we just need the user ID for routing notifications.
// A proper implementation would validate the JWT.
client.UserID = "pending-auth" // Placeholder
h.addClient(client)
}
case "ping":
msg := Message{Type: "pong"}
data, _ := json.Marshal(msg)
client.Conn.Write(ctx, websocket.MessageText, data)
}
}
}
// SetClientUserID updates the user ID after JWT validation.
// Called by the sync handler when it knows the real user ID.
func (h *Hub) SetClientUserID(client *Client, userID string) {
h.mu.Lock()
defer h.mu.Unlock()
// Remove from old mapping
if client.UserID != "" {
if clients, ok := h.clients[client.UserID]; ok {
delete(clients, client)
if len(clients) == 0 {
delete(h.clients, client.UserID)
}
}
}
// Add to new mapping
client.UserID = userID
if _, ok := h.clients[userID]; !ok {
h.clients[userID] = make(map[*Client]struct{})
}
h.clients[userID][client] = struct{}{}
}
func (h *Hub) addClient(client *Client) {
if client.UserID == "" {
return
}
h.mu.Lock()
defer h.mu.Unlock()
if _, ok := h.clients[client.UserID]; !ok {
h.clients[client.UserID] = make(map[*Client]struct{})
}
h.clients[client.UserID][client] = struct{}{}
slog.Info("client connected", "userID", client.UserID, "appID", client.AppID)
}
func (h *Hub) removeClient(client *Client) {
if client.UserID == "" {
return
}
h.mu.Lock()
defer h.mu.Unlock()
if clients, ok := h.clients[client.UserID]; ok {
delete(clients, client)
if len(clients) == 0 {
delete(h.clients, client.UserID)
}
}
slog.Info("client disconnected", "userID", client.UserID, "appID", client.AppID)
}
// ConnectedUsers returns the number of unique connected users.
func (h *Hub) ConnectedUsers() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.clients)
}
// TotalConnections returns the total number of WebSocket connections.
func (h *Hub) TotalConnections() int {
h.mu.RLock()
defer h.mu.RUnlock()
total := 0
for _, clients := range h.clients {
total += len(clients)
}
return total
}