Skip to content

Commit a1fdd48

Browse files
committed
feat: (WIP) session management for websocket conn in SSC
1 parent 440677c commit a1fdd48

File tree

9 files changed

+479
-32
lines changed

9 files changed

+479
-32
lines changed

docs/articles/api/host.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ The `host` package lets Go code back HTML components with server side logic. Com
66
| --- | --- |
77
| `HostComponent` | Couples a component name with a handler. |
88
| `Register(hc *HostComponent)` | Adds a component to the global registry. |
9+
| `HandlerWithSession` | Function signature `func(*Session, map[string]any) any`. |
10+
| `NewHostComponentWithSession(name string, handler HandlerWithSession) *HostComponent` | Registers a session-aware handler that receives per-connection context. |
11+
| `(*HostComponent).HandleWithSession(session *Session, payload map[string]any) any` | Executes the session-aware callback, falling back to legacy handlers. |
912
| `ListenAndServe(addr, root string)` | Serves files and exposes the WebSocket endpoint. |
1013
| `NewMux(root string)` | Returns a configured `*http.ServeMux`. |
14+
| `Broadcast(name string, payload any, opts ...BroadcastOption)` | Sends a payload to all subscribers or to a filtered session set. |
15+
| `WithSessionTarget(sessionID string) BroadcastOption` | Restricts a broadcast to a single session ID. |
16+
| `SessionByID(id string) (*Session, bool)` | Looks up the session currently attached to a WebSocket. |
17+
| `(*Session).ID() string` | Returns the server-generated session identifier. |
18+
| `(*Session).StoreManager() *state.StoreManager` | Exposes an isolated store manager for the connection. |
19+
| `(*Session).ContextSet/Get/Delete` | Manage arbitrary per-session context data. |
20+
| `(*Session).Snapshot() map[string]map[string]map[string]any` | Captures all stores registered in the session. |
21+
22+
See the [SSC guide](../guide/ssc.md#session-scoped-hydration-data) for usage patterns.
1123

docs/articles/api/hostclient.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,7 @@ The `hostclient` runtime runs inside the WebAssembly bundle and maintains a WebS
88
| `Send(name string, payload any)` | Serialise and write a payload to the socket. |
99
| `RegisterHandler(name string, h func(map[string]any))` | Attach a callback for inbound payloads. |
1010
| `EnableDebug()` | Log WebSocket connection and message events. |
11+
| `SessionID() string` | Returns the last session identifier received from the host (also injected as `_session` in handler payloads). |
12+
13+
See the [SSC guide](../guide/ssc.md#session-scoped-hydration-data) for session-aware patterns.
1114

v1/host/host_component.go

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,72 @@
11
package host
22

3+
import "github.com/rfwlab/rfw/v1/state"
4+
35
// Handler processes inbound payloads for a HostComponent and returns a
46
// response payload to send back to the wasm runtime. Returning nil results in
57
// no message being sent.
68
type Handler func(payload map[string]any) any
79

10+
// HandlerWithSession processes inbound payloads with the associated Session.
11+
type HandlerWithSession func(*Session, map[string]any) any
12+
813
// HostComponent represents server-side logic backing an HTML component.
914
type HostComponent struct {
10-
name string
11-
handler Handler
15+
name string
16+
handler Handler
17+
sessionHandler HandlerWithSession
1218
}
1319

1420
// NewHostComponent registers a handler for the given component name.
1521
func NewHostComponent(name string, handler Handler) *HostComponent {
16-
return &HostComponent{name: name, handler: handler}
22+
hc := &HostComponent{name: name, handler: handler}
23+
if handler != nil {
24+
hc.sessionHandler = func(_ *Session, payload map[string]any) any {
25+
return handler(payload)
26+
}
27+
}
28+
return hc
1729
}
1830

1931
func (hc *HostComponent) Name() string { return hc.name }
2032

2133
// Handle executes the component's handler.
2234
func (hc *HostComponent) Handle(payload map[string]any) any {
35+
if hc.handler == nil {
36+
return nil
37+
}
38+
return hc.handler(payload)
39+
}
40+
41+
// NewHostComponentWithSession registers a session-aware handler.
42+
func NewHostComponentWithSession(name string, handler HandlerWithSession) *HostComponent {
43+
return &HostComponent{name: name, sessionHandler: handler}
44+
}
45+
46+
// HandleWithSession executes the session-aware handler when available.
47+
func (hc *HostComponent) HandleWithSession(session *Session, payload map[string]any) any {
48+
if hc.sessionHandler != nil {
49+
return hc.sessionHandler(session, payload)
50+
}
2351
if hc.handler != nil {
2452
return hc.handler(payload)
2553
}
2654
return nil
2755
}
2856

57+
// SessionAware reports whether the component registered a session handler.
58+
func (hc *HostComponent) SessionAware() bool { return hc.sessionHandler != nil }
59+
60+
// StoreManager returns the session-specific store manager when available.
61+
// If session is nil a reference to the global manager is returned for
62+
// backward compatibility with legacy handlers.
63+
func (hc *HostComponent) StoreManager(session *Session) *state.StoreManager {
64+
if session != nil {
65+
return session.StoreManager()
66+
}
67+
return state.GlobalStoreManager
68+
}
69+
2970
var registry = make(map[string]*HostComponent)
3071

3172
// Register adds a HostComponent to the global registry so incoming messages

v1/host/host_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,33 @@ func TestHostComponent(t *testing.T) {
2424
}
2525
}
2626

27+
func TestHostComponentWithSession(t *testing.T) {
28+
hc := NewHostComponentWithSession("withSession", func(session *Session, payload map[string]any) any {
29+
if session == nil {
30+
t.Fatalf("session should not be nil")
31+
}
32+
store := session.StoreManager().NewStore("test")
33+
store.Set("value", payload["v"])
34+
return store.Snapshot()
35+
})
36+
37+
sess := newSession("abc")
38+
resp := hc.HandleWithSession(sess, map[string]any{"v": 42})
39+
snap, ok := resp.(map[string]any)
40+
if !ok {
41+
t.Fatalf("unexpected response type %T", resp)
42+
}
43+
if snap["value"] != 42 {
44+
t.Fatalf("unexpected store snapshot: %v", snap)
45+
}
46+
if !hc.SessionAware() {
47+
t.Fatalf("expected session aware component")
48+
}
49+
if hc.StoreManager(sess) != sess.StoreManager() {
50+
t.Fatalf("StoreManager helper mismatch")
51+
}
52+
}
53+
2754
// TestLogLevel checks environment variable parsing.
2855
func TestLogLevel(t *testing.T) {
2956
t.Setenv("RFW_LOG_LEVEL", "debug")

v1/host/session.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package host
2+
3+
import (
4+
"crypto/rand"
5+
"encoding/hex"
6+
"sync"
7+
8+
"github.com/rfwlab/rfw/v1/state"
9+
)
10+
11+
// Session represents per-connection state for a WebSocket client.
12+
// It exposes an isolated StoreManager and a context bag for arbitrary data.
13+
type Session struct {
14+
id string
15+
stores *state.StoreManager
16+
17+
ctxMu sync.RWMutex
18+
ctx map[string]any
19+
}
20+
21+
func newSession(id string) *Session {
22+
return &Session{
23+
id: id,
24+
stores: state.NewStoreManager(),
25+
ctx: make(map[string]any),
26+
}
27+
}
28+
29+
func (s *Session) ID() string { return s.id }
30+
31+
func (s *Session) StoreManager() *state.StoreManager { return s.stores }
32+
33+
// ContextGet retrieves a value from the session context.
34+
func (s *Session) ContextGet(key string) (any, bool) {
35+
s.ctxMu.RLock()
36+
defer s.ctxMu.RUnlock()
37+
v, ok := s.ctx[key]
38+
return v, ok
39+
}
40+
41+
// ContextSet stores a value in the session context.
42+
func (s *Session) ContextSet(key string, value any) {
43+
s.ctxMu.Lock()
44+
s.ctx[key] = value
45+
s.ctxMu.Unlock()
46+
}
47+
48+
// ContextDelete removes a value from the session context.
49+
func (s *Session) ContextDelete(key string) {
50+
s.ctxMu.Lock()
51+
delete(s.ctx, key)
52+
s.ctxMu.Unlock()
53+
}
54+
55+
// Snapshot returns a copy of all stores registered in this session.
56+
func (s *Session) Snapshot() map[string]map[string]map[string]any {
57+
return s.stores.Snapshot()
58+
}
59+
60+
var (
61+
sessionMu sync.RWMutex
62+
sessions = make(map[string]*Session)
63+
)
64+
65+
func allocateSession() *Session {
66+
id := generateSessionID()
67+
session := newSession(id)
68+
sessionMu.Lock()
69+
sessions[id] = session
70+
sessionMu.Unlock()
71+
return session
72+
}
73+
74+
func releaseSession(session *Session) {
75+
if session == nil {
76+
return
77+
}
78+
sessionMu.Lock()
79+
delete(sessions, session.id)
80+
sessionMu.Unlock()
81+
}
82+
83+
// SessionByID retrieves a session for the given ID.
84+
func SessionByID(id string) (*Session, bool) {
85+
sessionMu.RLock()
86+
defer sessionMu.RUnlock()
87+
s, ok := sessions[id]
88+
return s, ok
89+
}
90+
91+
func generateSessionID() string {
92+
buf := make([]byte, 16)
93+
if _, err := rand.Read(buf); err != nil {
94+
panic(err)
95+
}
96+
return hex.EncodeToString(buf)
97+
}

0 commit comments

Comments
 (0)