host
packageAPI reference for the host
package.
Imports
(34)context
STD
errors
STD
testing
STD
encoding/json
STD
net/http/httptest
STD
os
STD
path/filepath
STD
strings
STD
sync
STD
time
PKG
golang.org/x/net/websocket
STD
fmt
STD
net/http
STD
testing/fstest
STD
io
STD
crypto/rand
STD
encoding/hex
INT
github.com/rfwlab/rfw/v2/state
STD
html
STD
bufio
STD
net
STD
crypto/rsa
STD
crypto/tls
STD
crypto/x509
STD
encoding/pem
STD
expvar
STD
math/big
STD
net/http/pprof
STD
strconv
STD
bytes
STD
log/slog
STD
io/fs
STD
sync/atomic
STD
log
TestTypedActionRejectsUnknownFields
Parameters
func TestTypedActionRejectsUnknownFields(t *testing.T)
{
type request struct {
Name string `json:"name"`
}
type response struct {
Greeting string `json:"greeting"`
}
const name = "test.typed.strict"
if err := RegisterAction(name, func(_ context.Context, _ *Session, request request) (response, error) {
return response{Greeting: "hello " + request.Name}, nil
}); err != nil {
t.Fatalf("register action: %v", err)
}
result, actionErr := DispatchAction(context.Background(), newSession("typed"), name, map[string]any{"name": "Ada"})
if actionErr != nil {
t.Fatalf("dispatch action: %v", actionErr)
}
if result.(response).Greeting != "hello Ada" {
t.Fatalf("unexpected response: %#v", result)
}
_, actionErr = DispatchAction(context.Background(), newSession("strict"), name, map[string]any{
"name": "Ada",
"admin": true,
})
if actionErr == nil || actionErr.Code != "invalid_request" {
t.Fatalf("unknown field was accepted: %#v", actionErr)
}
}
TestTypedActionAuthorizationHidesInternalError
Parameters
func TestTypedActionAuthorizationHidesInternalError(t *testing.T)
{
type request struct {
Owner string `json:"owner"`
}
const name = "test.typed.authorized"
if err := RegisterAction(name,
func(_ context.Context, _ *Session, request request) (request, error) {
return request, nil
},
WithActionAuthorizer(func(_ context.Context, _ *Session, request request) error {
if request.Owner != "allowed" {
return errors.New("database policy detail")
}
return nil
}),
); err != nil {
t.Fatalf("register action: %v", err)
}
_, actionErr := DispatchAction(context.Background(), newSession("denied"), name, map[string]any{"owner": "denied"})
if actionErr == nil || actionErr.Code != "forbidden" || actionErr.Message != "action forbidden" {
t.Fatalf("unexpected authorization response: %#v", actionErr)
}
}
TestTypedFormReturnsFieldErrors
Parameters
func TestTypedFormReturnsFieldErrors(t *testing.T)
{
type values struct {
Email string `json:"email"`
}
type result struct {
ID int `json:"id"`
}
const name = "test.form.validation"
if err := RegisterForm(name,
func(values values) FieldErrors {
if values.Email == "" {
return FieldErrors{"email": "required"}
}
return nil
},
func(_ context.Context, _ *Session, _ values) (result, error) {
return result{ID: 7}, nil
},
); err != nil {
t.Fatalf("register form: %v", err)
}
raw, actionErr := DispatchAction(context.Background(), newSession("form"), name, map[string]any{})
if actionErr != nil {
t.Fatalf("dispatch form: %v", actionErr)
}
response := raw.(FormResponse[result])
if response.Valid || response.Fields["email"] != "required" {
t.Fatalf("unexpected form response: %#v", response)
}
}
TestClientCanUnsubscribeFromBroadcasts
Parameters
func TestClientCanUnsubscribeFromBroadcasts(t *testing.T)
{
const componentName = "broadcast-unsubscribe"
Register(NewHostComponent(componentName, func(map[string]any) any {
return map[string]any{"ready": true}
}))
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("ok"), 0o600); err != nil {
t.Fatalf("write index: %v", err)
}
srv := httptest.NewServer(NewMux(root))
defer srv.Close()
ws, err := websocket.Dial("ws"+strings.TrimPrefix(srv.URL, "http")+"/ws", "", srv.URL)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer closeTestResource(t, ws)
init, _ := json.Marshal(map[string]any{"component": componentName, "payload": map[string]any{"init": true}})
if err := websocket.Message.Send(ws, init); err != nil {
t.Fatalf("subscribe: %v", err)
}
var raw []byte
if err := websocket.Message.Receive(ws, &raw); err != nil {
t.Fatalf("receive init: %v", err)
}
unsubscribe, _ := json.Marshal(map[string]any{"component": componentName, "payload": map[string]any{"unsubscribe": true}})
if err := websocket.Message.Send(ws, unsubscribe); err != nil {
t.Fatalf("unsubscribe: %v", err)
}
if err := websocket.Message.Receive(ws, &raw); err != nil {
t.Fatalf("receive unsubscribe acknowledgement: %v", err)
}
Broadcast(componentName, map[string]any{"unexpected": true})
if err := ws.SetDeadline(time.Now().Add(50 * time.Millisecond)); err != nil {
t.Fatalf("set deadline: %v", err)
}
if err := websocket.Message.Receive(ws, &raw); err == nil {
t.Fatalf("received broadcast after unsubscribe: %s", raw)
}
if err := ws.SetDeadline(time.Time{}); err != nil {
t.Fatalf("reset deadline: %v", err)
}
}
TestBroadcastConcurrentWithConnectionChurn
Broadcast must snapshot the (conn, session) pairs under connMu: clients
subscribing and disconnecting concurrently with broadcasts used to race with
the map iteration. Run with -race to exercise the invariant.
Parameters
func TestBroadcastConcurrentWithConnectionChurn(t *testing.T)
{
const componentName = "broadcast-churn"
Register(NewHostComponentWithSession(componentName, func(_ *Session, _ map[string]any) any {
return map[string]any{"ok": true}
}))
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("ok"), 0o600); err != nil {
t.Fatalf("write index: %v", err)
}
srv := httptest.NewServer(NewMux(root))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws"
init, err := json.Marshal(map[string]any{
"component": componentName,
"payload": map[string]any{"init": true},
})
if err != nil {
t.Fatalf("marshal init: %v", err)
}
var wg sync.WaitGroup
// Churn: connections subscribe, receive the handler response and close.
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 5; j++ {
ws, err := websocket.Dial(wsURL, "", srv.URL)
if err != nil {
continue
}
if err := websocket.Message.Send(ws, init); err == nil {
var raw []byte
_ = websocket.Message.Receive(ws, &raw)
}
closeTestResource(t, ws)
}
}()
}
// Broadcasters run against the same component while the map churns.
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 50; j++ {
Broadcast(componentName, map[string]any{"tick": j})
}
}()
}
wg.Wait()
}
Inbound
Inbound is a client-to-host SSC protocol message.
type Inbound struct
Fields
| Name | Type | Description |
|---|---|---|
| Component | string | json:"component,omitempty" |
| Action | string | json:"action,omitempty" |
| ID | string | json:"id,omitempty" |
| Payload | map[string]any | json:"payload,omitempty" |
| Sequence | uint64 | json:"sequence,omitempty" |
| Ack | uint64 | json:"ack,omitempty" |
| ResumeToken | string | json:"resumeToken,omitempty" |
Outbound
Outbound is a host-to-client SSC protocol message.
type Outbound struct
Fields
| Name | Type | Description |
|---|---|---|
| Component | string | json:"component,omitempty" |
| Action | string | json:"action,omitempty" |
| Control | string | json:"control,omitempty" |
| ID | string | json:"id,omitempty" |
| Payload | any | json:"payload,omitempty" |
| Error | *ActionError | json:"error,omitempty" |
| Session | string | json:"session,omitempty" |
| Sequence | uint64 | json:"sequence,omitempty" |
| Ack | uint64 | json:"ack,omitempty" |
| ResumeToken | string | json:"resumeToken,omitempty" |
ActionError
ActionError is a public, machine-readable action failure.
type ActionError struct
Methods
Returns
func (*ActionError) Error() string
{
if e == nil {
return ""
}
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
Fields
| Name | Type | Description |
|---|---|---|
| Code | string | json:"code" |
| Message | string | json:"message" |
| Fields | map[string]string | json:"fields,omitempty" |
NewActionError
NewActionError creates a public action error safe to return to the client.
Parameters
Returns
func NewActionError(code, message string) *ActionError
{
return &ActionError{Code: code, Message: message}
}
TestNewMuxDebugEndpoints
Parameters
func TestNewMuxDebugEndpoints(t *testing.T)
{
t.Setenv("RFW_DEVTOOLS", "1")
mux := NewMux(t.TempDir())
ts := httptest.NewServer(mux)
defer ts.Close()
resp, err := http.Get(ts.URL + "/debug/vars")
if err != nil {
t.Fatalf("vars request failed: %v", err)
}
if err := resp.Body.Close(); err != nil {
t.Fatalf("close debug response: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
resp, err = http.Get(ts.URL + "/debug/pprof/")
if err != nil {
t.Fatalf("pprof request failed: %v", err)
}
if err := resp.Body.Close(); err != nil {
t.Fatalf("close pprof response: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
testClientFS
Returns
func testClientFS() fstest.MapFS
{
return fstest.MapFS{
"index.html": {Data: []byte("<!doctype html><div id=app></div>")},
"app.wasm": {Data: []byte("\x00asm")},
"app.wasm.br": {Data: []byte("brotli-bytes")},
"rfw_config.js": {Data: []byte("//cfg")},
"assets/app.css": {Data: []byte(".a{}")},
}
}
TestNewMuxFSServesEmbeddedBuild
Parameters
func TestNewMuxFSServesEmbeddedBuild(t *testing.T)
{
srv := httptest.NewServer(NewMuxFS(testClientFS()))
defer srv.Close()
type result struct {
status int
header http.Header
}
// get issues the request and closes the body before returning, so the
// assertions never hold an open response.
get := func(path, accept string) result {
req, err := http.NewRequest(http.MethodGet, srv.URL+path, nil)
if err != nil {
t.Fatalf("new request %s: %v", path, err)
}
if accept != "" {
req.Header.Set("Accept", accept)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("get %s: %v", path, err)
}
if cerr := resp.Body.Close(); cerr != nil {
t.Fatalf("close body %s: %v", path, cerr)
}
return result{status: resp.StatusCode, header: resp.Header}
}
if got := get("/", "text/html").status; got != http.StatusOK {
t.Fatalf("root status = %d, want 200", got)
}
if got := get("/assets/app.css", "").status; got != http.StatusOK {
t.Fatalf("nested asset status = %d, want 200", got)
}
if got := get("/app.wasm?v=abc", "").header.Get("Cache-Control"); got != "public, max-age=31536000, immutable" {
t.Fatalf("versioned wasm Cache-Control = %q", got)
}
if got := get("/app.wasm.br", "").header.Get("Content-Encoding"); got != "br" {
t.Fatalf("wasm.br Content-Encoding = %q, want br", got)
}
if got := get("/rfw_config.js", "").header.Get("Cache-Control"); got != "no-cache" {
t.Fatalf("rfw_config.js Cache-Control = %q, want no-cache", got)
}
// An unknown HTML route falls back to index.html (single-page app).
if got := get("/dashboard/live", "text/html").status; got != http.StatusOK {
t.Fatalf("unknown html route status = %d, want 200 (index fallback)", got)
}
// A missing non-HTML asset is a 404, not the index.
if got := get("/missing.css", "").status; got != http.StatusNotFound {
t.Fatalf("missing asset status = %d, want 404", got)
}
// A plain GET is not a WebSocket handshake, so /ws rejects it, but it must be
// routed rather than falling through to the catch-all 404.
if got := get("/ws", "").status; got == http.StatusNotFound {
t.Fatalf("/ws returned 404, endpoint not registered")
}
}
wsProbe
Parameters
Returns
func wsProbe(t *testing.T, mux *http.ServeMux, origin string) int
{
t.Helper()
srv := httptest.NewServer(mux)
defer srv.Close()
req, err := http.NewRequest(http.MethodGet, srv.URL+"/ws", nil)
if err != nil {
t.Fatalf("request: %v", err)
}
if origin != "" {
req.Header.Set("Origin", origin)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
if err := resp.Body.Close(); err != nil {
t.Fatalf("close response: %v", err)
}
return resp.StatusCode
}
TestWSOpenByDefault
Without options the endpoint stays open: a plain GET reaches the WebSocket
handler (which rejects the missing upgrade with 400, not a guard status).
Parameters
func TestWSOpenByDefault(t *testing.T)
{
mux := NewMux(t.TempDir())
if code := wsProbe(t, mux, "http://evil.example"); code == http.StatusForbidden || code == http.StatusUnauthorized {
t.Fatalf("default mux rejected connection: %d", code)
}
}
TestWSOriginAllowlist
Parameters
func TestWSOriginAllowlist(t *testing.T)
{
mux := NewMux(t.TempDir(), WithOriginAllowlist("https://app.example.com"))
if code := wsProbe(t, mux, "http://evil.example"); code != http.StatusForbidden {
t.Fatalf("expected 403 for unlisted origin, got %d", code)
}
if code := wsProbe(t, mux, ""); code != http.StatusForbidden {
t.Fatalf("expected 403 for missing origin, got %d", code)
}
if code := wsProbe(t, mux, "https://app.example.com"); code == http.StatusForbidden {
t.Fatalf("allowed origin rejected: %d", code)
}
}
TestWSAuthFunc
Parameters
func TestWSAuthFunc(t *testing.T)
{
mux := NewMux(t.TempDir(), WithAuthFunc(func(r *http.Request) bool {
return r.Header.Get("Origin") == "https://trusted.example.com"
}))
if code := wsProbe(t, mux, "http://evil.example"); code != http.StatusUnauthorized {
t.Fatalf("expected 401 for rejected auth, got %d", code)
}
if code := wsProbe(t, mux, "https://trusted.example.com"); code == http.StatusUnauthorized {
t.Fatalf("accepted auth rejected: %d", code)
}
}
TestWSConnectionLimit
Parameters
func TestWSConnectionLimit(t *testing.T)
{
runtime := NewWSRuntime(WithSSCLimits(SSCLimits{MaxConnections: 1}))
if !runtime.AcquireConnection() {
t.Fatal("first connection was rejected")
}
if runtime.AcquireConnection() {
t.Fatal("second connection exceeded the limit")
}
runtime.ReleaseConnection()
if !runtime.AcquireConnection() {
t.Fatal("released connection slot was not reusable")
}
runtime.ReleaseConnection()
}
TestNewMuxServesBrotliWasmWithEncoding
Parameters
func TestNewMuxServesBrotliWasmWithEncoding(t *testing.T)
{
t.Setenv("RFW_DEVTOOLS", "")
root := t.TempDir()
clientDir := filepath.Join(root, "client")
if err := os.MkdirAll(clientDir, 0o755); err != nil {
t.Fatalf("failed to create client dir: %v", err)
}
wasmPath := filepath.Join(clientDir, "app.wasm.br")
if err := os.WriteFile(wasmPath, []byte("compressed"), 0o644); err != nil {
t.Fatalf("failed to write wasm: %v", err)
}
if err := os.WriteFile(filepath.Join(clientDir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("failed to write index: %v", err)
}
if err := os.WriteFile(filepath.Join(clientDir, "rfw_config.js"), []byte("//cfg"), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
mux := NewMux(clientDir)
srv := httptest.NewServer(mux)
defer srv.Close()
resp, err := http.Get(srv.URL + "/app.wasm.br?v=abc123")
if err != nil {
t.Fatalf("failed to get wasm: %v", err)
}
defer closeTestResource(t, resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("unexpected status: %d", resp.StatusCode)
}
if enc := resp.Header.Get("Content-Encoding"); enc != "br" {
t.Fatalf("expected Content-Encoding br, got %q", enc)
}
if ct := resp.Header.Get("Content-Type"); ct != "application/wasm" {
t.Fatalf("expected Content-Type application/wasm, got %q", ct)
}
if cache := resp.Header.Get("Cache-Control"); cache != "public, max-age=31536000, immutable" {
t.Fatalf("unexpected Cache-Control header: %q", cache)
}
if vary := resp.Header.Get("Vary"); vary != "Accept-Encoding" && vary != "Accept-Encoding, Accept-Encoding" {
// Allow duplicated value as Go's header may append values depending on environment.
t.Fatalf("unexpected Vary header: %q", vary)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read body: %v", err)
}
if string(body) != "compressed" {
t.Fatalf("unexpected body: %q", string(body))
}
resp, err = http.Get(srv.URL + "/app.wasm.br?v=")
if err != nil {
t.Fatalf("failed to get unversioned wasm: %v", err)
}
defer closeTestResource(t, resp.Body)
if cache := resp.Header.Get("Cache-Control"); cache != "no-cache" {
t.Fatalf("unexpected unversioned Cache-Control header: %q", cache)
}
resp, err = http.Get(srv.URL + "/rfw_config.js")
if err != nil {
t.Fatalf("failed to get runtime config: %v", err)
}
defer closeTestResource(t, resp.Body)
if cache := resp.Header.Get("Cache-Control"); cache != "no-cache" {
t.Fatalf("unexpected runtime config Cache-Control header: %q", cache)
}
}
TestNewMuxDevModeNoStore
TestNewMuxDevModeNoStore verifies that under rfw dev (RFW_DEV_BUILD=1) every
asset, including a versioned wasm and rfw_config.js, is served no-store so a
rebuild is never masked by an immutable cache entry.
Parameters
func TestNewMuxDevModeNoStore(t *testing.T)
{
t.Setenv("RFW_DEVTOOLS", "")
t.Setenv("RFW_DEV_BUILD", "1")
root := t.TempDir()
clientDir := filepath.Join(root, "client")
if err := os.MkdirAll(clientDir, 0o755); err != nil {
t.Fatalf("failed to create client dir: %v", err)
}
if err := os.WriteFile(filepath.Join(clientDir, "app.wasm"), []byte("wasm"), 0o644); err != nil {
t.Fatalf("failed to write wasm: %v", err)
}
if err := os.WriteFile(filepath.Join(clientDir, "rfw_config.js"), []byte("//cfg"), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
if err := os.WriteFile(filepath.Join(clientDir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("failed to write index: %v", err)
}
mux := NewMux(clientDir)
srv := httptest.NewServer(mux)
defer srv.Close()
for _, path := range []string{"/app.wasm?v=abc123", "/rfw_config.js", "/"} {
resp, err := http.Get(srv.URL + path)
if err != nil {
t.Fatalf("get %s: %v", path, err)
}
cache := resp.Header.Get("Cache-Control")
closeTestResource(t, resp.Body)
if cache != "no-store" {
t.Fatalf("dev %s: expected no-store, got %q", path, cache)
}
}
}
Session
Session represents per-connection state for a WebSocket client.
It exposes an isolated StoreManager and a context bag for arbitrary data.
type Session struct
Methods
ResumeToken returns the opaque token used to resume this session.
Returns
func (*Session) ResumeToken() string
{ return s.resumeToken }
StoreManager returns the session-local store registry.
Returns
func (*Session) StoreManager() *state.StoreManager
{ return s.stores }
ContextGet retrieves a value from the session context.
Parameters
Returns
func (*Session) ContextGet(key string) (any, bool)
{
s.ctxMu.RLock()
defer s.ctxMu.RUnlock()
v, ok := s.ctx[key]
return v, ok
}
ContextSet stores a value in the session context.
Parameters
func (*Session) ContextSet(key string, value any)
{
s.ctxMu.Lock()
s.ctx[key] = value
s.ctxMu.Unlock()
}
ContextDelete removes a value from the session context.
Parameters
func (*Session) ContextDelete(key string)
{
s.ctxMu.Lock()
delete(s.ctx, key)
s.ctxMu.Unlock()
}
Snapshot returns a copy of all stores registered in this session.
Returns
func (*Session) Snapshot() map[string]map[string]map[string]any
{
return s.stores.Snapshot()
}
AcceptInbound validates and records an inbound sequence.
Parameters
Returns
func (*Session) AcceptInbound(sequence uint64) error
{
if s == nil || sequence == 0 {
return nil
}
s.deliveryMu.Lock()
defer s.deliveryMu.Unlock()
if sequence <= s.inboundSeq {
return ErrDuplicateMessage
}
if s.inboundSeq != 0 && sequence != s.inboundSeq+1 {
return ErrSequenceGap
}
s.inboundSeq = sequence
return nil
}
AllowMessage enforces a fixed per-session message window.
Parameters
Returns
func (*Session) AllowMessage(limit int) bool
{
if s == nil || limit <= 0 {
return true
}
s.deliveryMu.Lock()
defer s.deliveryMu.Unlock()
now := time.Now()
if s.rateStart.IsZero() || now.Sub(s.rateStart) >= time.Minute {
s.rateStart = now
s.rateCount = 0
}
s.rateCount++
return s.rateCount <= limit
}
PrepareOutbound assigns delivery metadata and stores replay history.
func (*Session) PrepareOutbound(out Outbound) Outbound
{
if s == nil {
return out
}
s.deliveryMu.Lock()
defer s.deliveryMu.Unlock()
s.outboundSeq++
out.Session = s.id
out.Sequence = s.outboundSeq
out.Ack = s.inboundSeq
out.ResumeToken = s.resumeToken
if s.replayLimit > 0 {
s.replay = append(s.replay, out)
if extra := len(s.replay) - s.replayLimit; extra > 0 {
copy(s.replay, s.replay[extra:])
s.replay = s.replay[:s.replayLimit]
}
}
return out
}
Acknowledge removes outbound messages confirmed by the client.
Parameters
func (*Session) Acknowledge(sequence uint64)
{
if s == nil || sequence == 0 {
return
}
s.deliveryMu.Lock()
defer s.deliveryMu.Unlock()
remove := 0
for remove < len(s.replay) && s.replay[remove].Sequence <= sequence {
remove++
}
if remove > 0 {
s.replay = append([]Outbound(nil), s.replay[remove:]...)
}
}
ReplayAfter returns retained outbound messages after sequence.
Parameters
Returns
func (*Session) ReplayAfter(sequence uint64) ([]Outbound, error)
{
if s == nil {
return nil, nil
}
s.deliveryMu.Lock()
defer s.deliveryMu.Unlock()
if len(s.replay) == 0 {
return nil, nil
}
if sequence+1 < s.replay[0].Sequence {
return nil, ErrReplayUnavailable
}
index := 0
for index < len(s.replay) && s.replay[index].Sequence <= sequence {
index++
}
return append([]Outbound(nil), s.replay[index:]...), nil
}
Fields
| Name | Type | Description |
|---|---|---|
| id | string | |
| resumeToken | string | |
| stores | *state.StoreManager | |
| ctxMu | sync.RWMutex | |
| ctx | map[string]any | |
| deliveryMu | sync.Mutex | |
| outboundMu | sync.Mutex | |
| connection | *websocket.Conn | |
| connectionManaged | bool | |
| resumePending | bool | |
| attached | bool | |
| released | bool | |
| expires | time.Time | |
| expiryTimer | *time.Timer | |
| inboundSeq | uint64 | |
| outboundSeq | uint64 | |
| rateStart | time.Time | |
| rateCount | int | |
| replayLimit | int | |
| replay | []Outbound |
sessionOptions
type sessionOptions struct
Fields
| Name | Type | Description |
|---|---|---|
| resumeToken | string | |
| replayLimit | int |
newSession
Parameters
Returns
func newSession(id string, options ...sessionOptions) *Session
{
var config sessionOptions
if len(options) > 0 {
config = options[0]
}
return &Session{
id: id,
resumeToken: config.resumeToken,
stores: state.NewStoreManager(),
ctx: make(map[string]any),
attached: true,
replayLimit: config.replayLimit,
}
}
AllocateSession
AllocateSession creates and registers a session.
Returns
func AllocateSession() *Session
{
session, _ := allocateSession(0, 0)
return session
}
AllocateResumableSession
AllocateResumableSession creates a session with ordered delivery history.
Parameters
Returns
func AllocateResumableSession(replayLimit int) *Session
{
session, _ := allocateSession(replayLimit, 0)
return session
}
allocateSession
Parameters
Returns
func allocateSession(replayLimit, maxSessions int) (*Session, error)
{
id := generateSessionID()
token := ""
if replayLimit > 0 {
token = generateSessionID() + generateSessionID()
}
session := newSession(id, sessionOptions{resumeToken: token, replayLimit: replayLimit})
sessionMu.Lock()
if maxSessions > 0 && len(sessions) >= maxSessions {
sessionMu.Unlock()
return nil, ErrSessionLimit
}
sessions[id] = session
if token != "" {
sessionByToken[token] = session
}
sessionMu.Unlock()
return session, nil
}
SuspendSession
SuspendSession detaches a connection and retains resumable state for ttl.
Parameters
func SuspendSession(session *Session, ttl time.Duration)
{
if session == nil {
return
}
session.outboundMu.Lock()
session.deliveryMu.Lock()
if !session.attached {
session.deliveryMu.Unlock()
session.outboundMu.Unlock()
return
}
session.connection = nil
session.attached = false
if ttl <= 0 || session.resumeToken == "" {
session.deliveryMu.Unlock()
session.outboundMu.Unlock()
ReleaseSession(session)
return
}
expires := time.Now().Add(ttl)
session.expires = expires
session.expiryTimer = time.AfterFunc(ttl, func() {
releaseSession(session, expires)
})
session.deliveryMu.Unlock()
session.outboundMu.Unlock()
}
ResumeSession
ResumeSession attaches a disconnected session by opaque token.
The new socket must call ReplaySession or BindSessionConnection before sends.
Parameters
Returns
func ResumeSession(token string) (*Session, bool)
{
if token == "" {
return nil, false
}
sessionMu.RLock()
session := sessionByToken[token]
sessionMu.RUnlock()
if session == nil {
return nil, false
}
session.deliveryMu.Lock()
defer session.deliveryMu.Unlock()
if session.released || session.attached || (!session.expires.IsZero() && time.Now().After(session.expires)) {
return nil, false
}
session.attached = true
session.resumePending = true
session.expires = time.Time{}
if session.expiryTimer != nil {
session.expiryTimer.Stop()
session.expiryTimer = nil
}
return session, true
}
ReleaseSession
ReleaseSession removes a session from the registry.
Parameters
func ReleaseSession(session *Session)
{
releaseSession(session, time.Time{})
}
releaseSession
func releaseSession(session *Session, expectedExpiry time.Time)
{
if session == nil {
return
}
session.outboundMu.Lock()
session.deliveryMu.Lock()
if session.released || (!expectedExpiry.IsZero() &&
(session.attached || !session.expires.Equal(expectedExpiry))) {
session.deliveryMu.Unlock()
session.outboundMu.Unlock()
return
}
session.released = true
if session.expiryTimer != nil {
session.expiryTimer.Stop()
session.expiryTimer = nil
}
session.connection = nil
session.connectionManaged = false
session.resumePending = false
session.attached = false
session.deliveryMu.Unlock()
session.outboundMu.Unlock()
sessionMu.Lock()
if sessions[session.id] == session {
delete(sessions, session.id)
}
if session.resumeToken != "" && sessionByToken[session.resumeToken] == session {
delete(sessionByToken, session.resumeToken)
}
sessionMu.Unlock()
}
SessionByID
SessionByID retrieves a session for the given ID.
Parameters
Returns
func SessionByID(id string) (*Session, bool)
{
sessionMu.RLock()
defer sessionMu.RUnlock()
s, ok := sessions[id]
return s, ok
}
generateSessionID
Returns
func generateSessionID() string
{
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
panic(err)
}
return hex.EncodeToString(buf)
}
Handler
Handler processes inbound payloads for a HostComponent and returns a
response payload to send back to the wasm runtime. Returning nil results in
no message being sent.
type Handler func(payload map[string]any) any
HandlerWithSession
HandlerWithSession processes inbound payloads with the associated Session.
type HandlerWithSession func(*Session, map[string]any) any
HostComponent
HostComponent represents server-side logic backing an HTML component.
type HostComponent struct
Methods
WithInitSnapshot registers a callback that produces an InitSnapshot when a resync is requested.
Parameters
Returns
func (*HostComponent) WithInitSnapshot(fn func(*Session, map[string]any) *InitSnapshot) *HostComponent
{
hc.initSnapshot = fn
return hc
}
Name returns the registered component name.
Returns
func (*HostComponent) Name() string
{ return hc.name }
Handle executes the component's handler.
Parameters
Returns
func (*HostComponent) Handle(payload map[string]any) any
{
if hc.handler != nil {
return hc.handler(payload)
}
return nil
}
HandleWithSession executes the session-aware handler when available.
Parameters
Returns
func (*HostComponent) HandleWithSession(session *Session, payload map[string]any) any
{
if payload != nil {
if _, ok := payload["resync"]; ok && hc.initSnapshot != nil {
if snap := hc.initSnapshot(session, payload); snap != nil {
return snap
}
}
}
if hc.sessionHandler != nil {
return hc.sessionHandler(session, payload)
}
if hc.handler != nil {
return hc.handler(payload)
}
return nil
}
SessionAware reports whether the component registered a session handler.
Returns
func (*HostComponent) SessionAware() bool
{ return hc.sessionHandler != nil }
StoreManager returns the session-specific store manager when available. If session is nil a reference to the global manager is returned for backward compatibility with legacy handlers.
Parameters
Returns
func (*HostComponent) StoreManager(session *Session) *state.StoreManager
{
if session != nil {
return session.StoreManager()
}
return state.GlobalStoreManager
}
Fields
| Name | Type | Description |
|---|---|---|
| name | string | |
| handler | Handler | |
| sessionHandler | HandlerWithSession | |
| initSnapshot | func(*Session, map[string]any) *InitSnapshot |
ServerComponent
ServerComponent is the concise name for HostComponent.
type ServerComponent HostComponent
InitSnapshot
InitSnapshot represents markup the host can send to force the client to
repaint a fragment. HTML is injected into the component root as raw HTML on
the client, so build it from the escaping helpers (Span, Div, P, Tag); any
unescaped user-derived data in it is an XSS vector. Use Raw/RawTag only for
markup you generated or sanitized yourself.
type InitSnapshot struct
Fields
| Name | Type | Description |
|---|---|---|
| HTML | string | json:"html" |
| Vars | []string | json:"vars,omitempty" |
NewHostComponent
NewHostComponent registers a handler for the given component name.
Parameters
Returns
func NewHostComponent(name string, handler Handler) *HostComponent
{
hc := &HostComponent{name: name, handler: handler}
if handler != nil {
hc.sessionHandler = func(_ *Session, payload map[string]any) any {
return handler(payload)
}
}
return hc
}
Uses
NewHostComponentWithSession
NewHostComponentWithSession registers a session-aware handler.
Parameters
Returns
func NewHostComponentWithSession(name string, handler HandlerWithSession) *HostComponent
{
return &HostComponent{name: name, sessionHandler: handler}
}
Component
Component is the interface for struct-based host components.
Register a struct implementing Component via RegisterComponent.
type Component interface
RegisterComponent
RegisterComponent creates a HostComponent from a struct implementing Component
and registers it in the global registry. This is the recommended way to
define host components — it provides type safety and clean separation.
Parameters
func RegisterComponent(c Component)
{
hc := &HostComponent{
name: c.Name(),
sessionHandler: func(s *Session, p map[string]any) any { return c.Serve(s, p) },
}
registryMu.Lock()
registry[c.Name()] = hc
registryMu.Unlock()
}
Uses
Register
Register adds a HostComponent to the global registry so incoming messages
can be routed to it.
Parameters
func Register(hc *HostComponent)
{
registryMu.Lock()
registry[hc.name] = hc
registryMu.Unlock()
}
Get
Get returns a registered HostComponent by name.
Parameters
Returns
func Get(name string) (*HostComponent, bool)
{
registryMu.RLock()
hc, ok := registry[name]
registryMu.RUnlock()
return hc, ok
}
validTagName
Parameters
Returns
func validTagName(tag string) bool
{
if tag == "" || !isASCIILetter(rune(tag[0])) {
return false
}
for _, char := range tag {
if isASCIILetter(char) || char >= '0' && char <= '9' || char == '-' {
continue
}
return false
}
return true
}
isASCIILetter
Parameters
Returns
func isASCIILetter(char rune) bool
{
return char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z'
}
hostVarTag
hostVarTag builds a host variable element. The value is HTML-escaped so
user-derived data cannot inject markup through the initial snapshot.
Parameters
Returns
func hostVarTag(tag, name string, value any, escape bool) string
{
if !validTagName(tag) {
return ""
}
v := fmt.Sprintf("%v", value)
body := v
expected := ""
if escape {
body = html.EscapeString(v)
expected = html.EscapeString(v)
}
return fmt.Sprintf(`<%s %s="%s" %s="%s">%s</%s>`,
tag, hostVarAttr, html.EscapeString(name), hostExpectedAttr, expected, body, tag)
}
Span
Span renders an escaped host variable in a span.
Parameters
Returns
func Span(name string, value any) string
{
return hostVarTag("span", name, value, true)
}
Div
Div renders an escaped host variable in a div.
Parameters
Returns
func Div(name string, value any) string
{
return hostVarTag("div", name, value, true)
}
P
P renders an escaped host variable in a paragraph.
Parameters
Returns
func P(name string, value any) string
{
return hostVarTag("p", name, value, true)
}
Tag
Tag renders an escaped host variable with tag.
Parameters
Returns
func Tag(tag, name string, value any) string
{
return hostVarTag(tag, name, value, true)
}
RawTag
RawTag builds a host variable element without escaping the value. It is the
explicit trust API for markup values: only pass HTML you generated or
sanitized yourself, never user-derived data.
Parameters
Returns
func RawTag(tag, name string, value any) string
{
return hostVarTag(tag, name, value, false)
}
Raw
Raw marks a fragment as trusted HTML and returns it unchanged. It exists to
make raw injection points explicit at call sites: anything passed through
Raw ends up in the client DOM unescaped via InitSnapshot.HTML.
Parameters
Returns
func Raw(html string) string
{
return html
}
Join
Join concatenates rendered host fragments.
Parameters
Returns
func Join(parts ...string) string
{
var b strings.Builder
for _, p := range parts {
b.WriteString(p)
}
return b.String()
}
TestSpan
Parameters
func TestSpan(t *testing.T)
{
got := Span("Visit", 1)
if !strings.Contains(got, `data-host-var="Visit"`) {
t.Fatalf("missing data-host-var: %s", got)
}
if !strings.Contains(got, `data-host-expected="1"`) {
t.Fatalf("missing data-host-expected: %s", got)
}
if !strings.Contains(got, ">1</span>") {
t.Fatalf("missing value: %s", got)
}
if !strings.HasPrefix(got, "<span ") {
t.Fatalf("wrong tag: %s", got)
}
}
TestDiv
Parameters
func TestDiv(t *testing.T)
{
got := Div("message", "hello")
if !strings.Contains(got, `data-host-var="message"`) {
t.Fatalf("missing data-host-var: %s", got)
}
if !strings.Contains(got, ">hello</div>") {
t.Fatalf("missing value: %s", got)
}
}
TestP
Parameters
func TestP(t *testing.T)
{
got := P("desc", "some text")
if !strings.Contains(got, `data-host-var="desc"`) {
t.Fatalf("missing data-host-var: %s", got)
}
if !strings.Contains(got, ">some text</p>") {
t.Fatalf("missing value: %s", got)
}
}
TestTag
Parameters
func TestTag(t *testing.T)
{
got := Tag("em", "count", 42)
if !strings.Contains(got, `data-host-var="count"`) {
t.Fatalf("missing data-host-var: %s", got)
}
if !strings.Contains(got, ">42</em>") {
t.Fatalf("missing value: %s", got)
}
}
TestHelpersEscapeValues
Helper values are HTML-escaped by default so user-derived data cannot
inject markup through the initial snapshot.
Parameters
func TestHelpersEscapeValues(t *testing.T)
{
got := Span("msg", `<img src=x onerror=alert(1)>`)
if !strings.Contains(got, "<img src=x onerror=alert(1)>") {
t.Fatalf("value not escaped: %s", got)
}
if strings.Contains(got, "><img") {
t.Fatalf("markup injected: %s", got)
}
if !strings.Contains(got, `data-host-expected="<img src=x onerror=alert(1)>"`) {
t.Fatalf("expected escaped migration value: %s", got)
}
}
TestRawTag
RawTag is the explicit trust API: the value passes through unescaped.
Parameters
func TestRawTag(t *testing.T)
{
got := RawTag("div", "content", `<b>ok</b>`)
if !strings.Contains(got, "><b>ok</b></div>") {
t.Fatalf("raw value escaped: %s", got)
}
if !strings.Contains(got, `data-host-expected=""`) {
t.Fatalf("raw markup must skip text expectation: %s", got)
}
}
TestRaw
Parameters
func TestRaw(t *testing.T)
{
html := `<div class="custom">foo</div>`
if got := Raw(html); got != html {
t.Fatalf("Raw should passthrough: got %q", got)
}
}
TestJoin
Parameters
func TestJoin(t *testing.T)
{
got := Join(Span("a", 1), Div("b", 2))
if !strings.Contains(got, `data-host-var="a"`) {
t.Fatalf("missing first var: %s", got)
}
if !strings.Contains(got, `data-host-var="b"`) {
t.Fatalf("missing second var: %s", got)
}
}
TestHostVariableAttributesAreEscaped
Parameters
func TestHostVariableAttributesAreEscaped(t *testing.T)
{
got := Span(`x" onmouseover="alert(1)`, "test")
if strings.Contains(got, `data-host-var="x" onmouseover=`) {
t.Fatalf("host variable name injected an attribute: %s", got)
}
if !strings.Contains(got, `data-host-var="x" onmouseover="alert(1)"`) {
t.Fatalf("host variable name was not escaped: %s", got)
}
}
TestTagRejectsInvalidName
Parameters
func TestTagRejectsInvalidName(t *testing.T)
{
if got := Tag(`div onmouseover="alert(1)"`, "x", "test"); got != "" {
t.Fatalf("invalid tag name was accepted: %s", got)
}
}
statusRecorder
type statusRecorder struct
Methods
Parameters
func (*statusRecorder) WriteHeader(code int)
{
r.status = code
r.ResponseWriter.WriteHeader(code)
}
Returns
func (*statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error)
{
if h, ok := r.ResponseWriter.(http.Hijacker); ok {
return h.Hijack()
}
return nil, nil, errors.New("http.Hijacker not supported")
}
Fields
| Name | Type | Description |
|---|---|---|
| status | int |
loggingMiddleware
Parameters
Returns
func loggingMiddleware(next http.Handler) http.Handler
{
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
start := time.Now()
next.ServeHTTP(rec, r)
logger.Info("request", "method", r.Method, "path", r.URL.Path, "status", rec.status, "duration", time.Since(start))
})
}
ResolveRoot
ResolveRoot resolves a content root relative to the executable when needed.
Parameters
Returns
func ResolveRoot(root string) string
{
if _, err := os.Stat(root); err == nil {
return root
}
if exe, err := os.Executable(); err == nil {
candidate := filepath.Join(filepath.Dir(exe), "..", root)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
return root
}
NewMux
NewMux returns an HTTP mux that serves static files from root and the
WebSocket handler at /ws. Options gate the WebSocket endpoint; by default it
accepts any origin and identity.
Parameters
Returns
func NewMux(root string, opts ...MuxOption) *http.ServeMux
{
root = ResolveRoot(root)
runtime := NewWSRuntime(opts...)
staticRoot := filepath.Join(root, "..", "static")
mux := http.NewServeMux()
if os.Getenv("RFW_DEVTOOLS") != "" {
mux.Handle("/debug/vars", expvar.Handler())
mux.HandleFunc("/debug/pprof/", pprof.Index)
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
}
fs := http.FileServer(http.Dir(root))
rootDir := http.Dir(root)
var sfs http.Handler
var staticDir http.Dir
if _, err := os.Stat(staticRoot); err == nil {
staticDir = http.Dir(staticRoot)
sfs = http.FileServer(staticDir)
}
if sfs != nil {
mux.Handle("/static/", http.StripPrefix("/static", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
setWasmEncodingHeaders(w, r.URL.Path, r.URL.Query().Get("v") != "")
sfs.ServeHTTP(w, r)
})))
}
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if sfs != nil {
if regularFile(staticDir, r.URL.Path) {
setWasmEncodingHeaders(w, r.URL.Path, r.URL.Query().Get("v") != "")
sfs.ServeHTTP(w, r)
return
}
}
if regularFile(rootDir, r.URL.Path) {
setWasmEncodingHeaders(w, r.URL.Path, r.URL.Query().Get("v") != "")
fs.ServeHTTP(w, r)
return
}
// Serve index.html only for HTML requests or bare paths to avoid
// returning HTML for CSS, JS, image, etc. requests.
accept := r.Header.Get("Accept")
if strings.Contains(accept, "text/html") || r.URL.Path == "/" || r.URL.Path == "" {
if devMode() {
w.Header().Set("Cache-Control", "no-store")
}
http.ServeFile(w, r, filepath.Join(root, "index.html"))
return
}
http.NotFound(w, r)
})
mux.Handle("/ws", runtime.Guard(websocket.Handler(func(ws *websocket.Conn) {
wsHandler(ws, runtime)
})))
return mux
}
ListenAndServe
ListenAndServe starts an HTTP server using NewMux to serve files and the
WebSocket endpoint.
Parameters
Returns
func ListenAndServe(addr, root string) error
{
logger.Info("serving HTTP", "addr", addr)
return newHTTPServer(addr, loggingMiddleware(NewMux(root))).ListenAndServe()
}
ListenAndServeWithMux
ListenAndServeWithMux starts an HTTP server using the provided mux.
Parameters
Returns
func ListenAndServeWithMux(addr string, mux *http.ServeMux) error
{
logger.Info("serving HTTP", "addr", addr)
return newHTTPServer(addr, loggingMiddleware(mux)).ListenAndServe()
}
ListenAndServeTLS
ListenAndServeTLS starts an HTTPS server using a self-signed certificate
and NewMux to serve files and the WebSocket endpoint.
Parameters
Returns
func ListenAndServeTLS(addr, root string) error
{
cert, err := generateSelfSignedCert()
if err != nil {
return err
}
srv := newHTTPServer(addr, loggingMiddleware(NewMux(root)))
srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
logger.Info("serving HTTPS", "addr", addr)
return srv.ListenAndServeTLS("", "")
}
ListenAndServeTLSWithMux
ListenAndServeTLSWithMux starts an HTTPS server using a self-signed certificate
and the provided mux, preserving any additional routes registered by callers.
Parameters
Returns
func ListenAndServeTLSWithMux(addr string, mux *http.ServeMux) error
{
cert, err := generateSelfSignedCert()
if err != nil {
return err
}
srv := newHTTPServer(addr, loggingMiddleware(mux))
srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
logger.Info("serving HTTPS", "addr", addr)
return srv.ListenAndServeTLS("", "")
}
newHTTPServer
Parameters
Returns
func newHTTPServer(addr string, handler http.Handler) *http.Server
{
return &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
}
}
regularFile
Parameters
Returns
func regularFile(root http.Dir, name string) bool
{
f, err := root.Open(name)
if err != nil {
return false
}
info, statErr := f.Stat()
closeErr := f.Close()
return statErr == nil && closeErr == nil && !info.IsDir()
}
devMode
devMode reports whether the server is running under rfw dev. The dev command
exports RFW_DEV_BUILD=1 and propagates it to the SSC host child via os.Environ,
so both the static and host-proxied serving paths observe it.
Returns
func devMode() bool
{ return os.Getenv("RFW_DEV_BUILD") == "1" }
setWasmEncodingHeaders
Parameters
func setWasmEncodingHeaders(w http.ResponseWriter, path string, versioned bool)
{
// In dev, nothing may be cached: the wasm version pointer lives in
// rfw_config.js and the binary is fetched as app.wasm?v=<hash>. Caching
// either one leaves the browser re-requesting a stale ?v= against an
// immutable entry, so rebuilds are never picked up. no-store on every asset
// forces a fresh fetch each load. Production keeps the immutable policy.
if devMode() {
w.Header().Set("Cache-Control", "no-store")
} else if strings.Trim(path, "/") == "rfw_config.js" {
// This small file points the loader at the content-versioned WASM URL.
// It must revalidate across deployments so an old pointer cannot keep a
// browser on an otherwise correctly immutable old binary.
w.Header().Set("Cache-Control", "no-cache")
}
if !strings.HasSuffix(path, ".wasm") && !strings.HasSuffix(path, ".wasm.br") {
return
}
header := w.Header()
if !devMode() {
if versioned {
header.Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
header.Set("Cache-Control", "no-cache")
}
}
if !strings.HasSuffix(path, ".wasm.br") {
return
}
header.Set("Content-Encoding", "br")
header.Set("Content-Type", "application/wasm")
if vary := header.Get("Vary"); vary == "" {
header.Set("Vary", "Accept-Encoding")
} else if !strings.Contains(vary, "Accept-Encoding") {
header.Set("Vary", vary+", Accept-Encoding")
}
}
generateSelfSignedCert
Returns
func generateSelfSignedCert() (tls.Certificate, error)
{
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return tls.Certificate{}, err
}
tmpl := x509.Certificate{
SerialNumber: big.NewInt(1),
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: []string{"localhost"},
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
if err != nil {
return tls.Certificate{}, err
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
return tls.X509KeyPair(certPEM, keyPEM)
}
TestSessionResumeAndReplay
Parameters
func TestSessionResumeAndReplay(t *testing.T)
{
session := AllocateResumableSession(4)
token := session.ResumeToken()
first := session.PrepareOutbound(Outbound{Component: "Counter", Payload: map[string]any{"value": 1}})
second := session.PrepareOutbound(Outbound{Component: "Counter", Payload: map[string]any{"value": 2}})
if first.Sequence != 1 || second.Sequence != 2 || token == "" {
t.Fatalf("missing delivery metadata: first=%#v second=%#v", first, second)
}
SuspendSession(session, time.Second)
resumed, ok := ResumeSession(token)
if !ok || resumed != session {
t.Fatal("session did not resume")
}
replay, err := resumed.ReplayAfter(first.Sequence)
if err != nil {
t.Fatalf("replay: %v", err)
}
if len(replay) != 1 || replay[0].Sequence != second.Sequence {
t.Fatalf("unexpected replay: %#v", replay)
}
ReleaseSession(session)
}
TestSessionRejectsDuplicatesAndGaps
Parameters
func TestSessionRejectsDuplicatesAndGaps(t *testing.T)
{
session := newSession("ordered")
if err := session.AcceptInbound(1); err != nil {
t.Fatalf("first message: %v", err)
}
if err := session.AcceptInbound(1); !errors.Is(err, ErrDuplicateMessage) {
t.Fatalf("duplicate result: %v", err)
}
if err := session.AcceptInbound(3); !errors.Is(err, ErrSequenceGap) {
t.Fatalf("gap result: %v", err)
}
if err := session.AcceptInbound(2); err != nil {
t.Fatalf("next message: %v", err)
}
}
TestSessionReplayReportsEvictedHistory
Parameters
func TestSessionReplayReportsEvictedHistory(t *testing.T)
{
session := AllocateResumableSession(1)
defer ReleaseSession(session)
session.PrepareOutbound(Outbound{Payload: "one"})
session.PrepareOutbound(Outbound{Payload: "two"})
if _, err := session.ReplayAfter(0); !errors.Is(err, ErrReplayUnavailable) {
t.Fatalf("expected replay error, got %v", err)
}
}
TestSessionAllocationLimit
Parameters
func TestSessionAllocationLimit(t *testing.T)
{
sessionMu.RLock()
current := len(sessions)
sessionMu.RUnlock()
session, err := allocateSession(1, current+1)
if err != nil {
t.Fatalf("allocate within limit: %v", err)
}
defer ReleaseSession(session)
if _, err := allocateSession(1, current+1); !errors.Is(err, ErrSessionLimit) {
t.Fatalf("expected session limit, got %v", err)
}
}
TestWithoutSSCResumeCreatesEphemeralSession
Parameters
func TestWithoutSSCResumeCreatesEphemeralSession(t *testing.T)
{
runtime := NewWSRuntime(WithoutSSCResume())
session, err := runtime.NewSession(nil)
if err != nil {
t.Fatalf("new session: %v", err)
}
if session.ResumeToken() != "" || runtime.ResumeTTL() != 0 {
t.Fatalf("resume was not disabled: token=%q ttl=%s", session.ResumeToken(), runtime.ResumeTTL())
}
ReleaseSession(session)
}
TestExpiredTimerDoesNotReleaseResumedSession
Parameters
func TestExpiredTimerDoesNotReleaseResumedSession(t *testing.T)
{
session := AllocateResumableSession(1)
defer ReleaseSession(session)
token := session.ResumeToken()
SuspendSession(session, time.Second)
session.deliveryMu.Lock()
expectedExpiry := session.expires
session.deliveryMu.Unlock()
if _, ok := ResumeSession(token); !ok {
t.Fatal("session did not resume")
}
releaseSession(session, expectedExpiry)
if current, ok := SessionByID(session.ID()); !ok || current != session {
t.Fatal("stale expiry released the resumed session")
}
}
readPort
Returns
func readPort() int
{
if override := strings.TrimSpace(os.Getenv("RFW_HOST_PORT")); override != "" {
if p, err := strconv.Atoi(override); err == nil && p > 0 {
return p
}
}
var manifest struct {
Port int `json:"port"`
}
data, err := os.ReadFile("rfw.json")
if err != nil {
return 8080
}
if err := json.Unmarshal(data, &manifest); err != nil {
return 8080
}
if manifest.Port == 0 {
return 8080
}
return manifest.Port
}
StartAuto
StartAuto launches HTTP and HTTPS servers serving files from the default
client build directory. It resolves the root path from rfw.json or falls
back to “build/client”. This is the recommended way to start the host server.
Returns
func StartAuto() error
{
root := resolveRoot()
return Start(root)
}
resolveRoot
Returns
func resolveRoot() string
{
// Check rfw.json for build configuration.
var manifest struct {
Build struct {
Dir string `json:"dir"`
} `json:"build"`
}
if data, err := os.ReadFile("rfw.json"); err == nil {
_ = json.Unmarshal(data, &manifest)
if manifest.Build.Dir != "" {
return manifest.Build.Dir
}
}
return "build/client"
}
Start
Start launches HTTP and HTTPS servers serving files from root.
The HTTPS port is the HTTP port + 1.
Parameters
Returns
func Start(root string) error
{
port := readPort()
httpsPort := port + 1
go func() {
addr := fmt.Sprintf(":%d", port)
if err := ListenAndServe(addr, root); err != nil {
logger.Error("HTTP server error", "err", err)
}
}()
httpsAddr := fmt.Sprintf(":%d", httpsPort)
return ListenAndServeTLS(httpsAddr, root)
}
TestReadPortOverride
Parameters
func TestReadPortOverride(t *testing.T)
{
t.Setenv("RFW_HOST_PORT", "9095")
if got := readPort(); got != 9095 {
t.Fatalf("expected override port 9095, got %d", got)
}
}
ActionHandler
ActionHandler handles a typed client action.
type ActionHandler func(context.Context, *Session, Request) (Response, error)
ActionAuthorizer
ActionAuthorizer can reject an action after the request is decoded.
type ActionAuthorizer func(context.Context, *Session, Request) error
actionConfig
type actionConfig struct
Fields
| Name | Type | Description |
|---|---|---|
| authorize | ActionAuthorizer[Request] |
ActionOption
ActionOption configures a typed action.
type ActionOption func(*actionConfig[Request])
WithActionAuthorizer
WithActionAuthorizer adds action-specific authorization.
Parameters
Returns
func WithActionAuthorizer[Request any](authorize ActionAuthorizer[Request]) ActionOption[Request]
{
return func(config *actionConfig[Request]) {
config.authorize = authorize
}
}
registeredAction
type registeredAction interface
Methods
typedAction
type typedAction struct
Fields
| Name | Type | Description |
|---|---|---|
| handler | ActionHandler[Request, Response] | |
| authorize | ActionAuthorizer[Request] |
RegisterAction
RegisterAction registers a strict, typed SSC action.
Parameters
Returns
func RegisterAction[Request, Response any](name string, handler ActionHandler[Request, Response], opts ...ActionOption[Request]) error
{
if name == "" {
return errors.New("host: empty action name")
}
if handler == nil {
return errors.New("host: nil action handler")
}
var config actionConfig[Request]
for _, opt := range opts {
opt(&config)
}
actionRegistry.Lock()
defer actionRegistry.Unlock()
if _, exists := actionRegistry.actions[name]; exists {
return fmt.Errorf("host: action %q already registered", name)
}
actionRegistry.actions[name] = typedAction[Request, Response]{
handler: handler,
authorize: config.authorize,
}
return nil
}
DispatchAction
DispatchAction decodes and executes a registered action.
Parameters
Returns
func DispatchAction(ctx context.Context, session *Session, name string, payload map[string]any) (any, *ActionError)
{
actionRegistry.RLock()
action := actionRegistry.actions[name]
actionRegistry.RUnlock()
if action == nil {
return nil, NewActionError("action_not_found", "action not found")
}
return action.dispatch(ctx, session, payload)
}
decodeActionPayload
Parameters
Returns
func decodeActionPayload(payload map[string]any, target any) error
{
if payload == nil {
payload = map[string]any{}
}
data, err := json.Marshal(payload)
if err != nil {
return errors.New("request payload is not valid JSON")
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return fmt.Errorf("invalid request: %w", err)
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return errors.New("invalid request: multiple JSON values")
}
return nil
}
publicActionError
Parameters
Returns
func publicActionError(err error, fallbackCode, fallbackMessage string) *ActionError
{
var actionErr *ActionError
if errors.As(err, &actionErr) {
return actionErr
}
return NewActionError(fallbackCode, fallbackMessage)
}
FieldErrors
FieldErrors maps form field names to validation messages.
type FieldErrors map[string]string
FormResponse
FormResponse is returned by typed form actions.
type FormResponse struct
Fields
| Name | Type | Description |
|---|---|---|
| Data | Response | json:"data,omitempty" |
| Fields | FieldErrors | json:"fields,omitempty" |
| Valid | bool | json:"valid" |
Uses
RegisterForm
RegisterForm registers a typed action with field validation.
Parameters
Returns
func RegisterForm[Values, Response any](name string, validate func(Values) FieldErrors, submit ActionHandler[Values, Response], opts ...ActionOption[Values]) error
{
if submit == nil {
return errors.New("host: nil form handler")
}
return RegisterAction(name, func(ctx context.Context, session *Session, values Values) (FormResponse[Response], error) {
if validate != nil {
if fields := validate(values); len(fields) > 0 {
return FormResponse[Response]{Fields: fields}, nil
}
}
data, err := submit(ctx, session, values)
if err != nil {
return FormResponse[Response]{}, err
}
return FormResponse[Response]{Data: data, Valid: true}, nil
}, opts...)
}
Uses
logLevel
Returns
func logLevel() slog.Level
{
switch strings.ToLower(os.Getenv("RFW_LOG_LEVEL")) {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
NewMuxFS
NewMuxFS is the fs.FS counterpart of NewMux: it serves the client build from
an fs.FS (for example an embed.FS sub-tree) instead of a directory on disk,
and registers the WebSocket handler at /ws. This lets an application ship as
a single self-contained binary with the build embedded via go:embed, or mount
the rfw endpoints on assets it already holds in memory.
fsys is treated as the complete served tree: index.html, app.wasm and any
static assets must live inside it. Unlike NewMux there is no on-disk sibling
static directory. Options gate the WebSocket endpoint exactly as in NewMux.
Parameters
Returns
func NewMuxFS(fsys fs.FS, opts ...MuxOption) *http.ServeMux
{
runtime := NewWSRuntime(opts...)
mux := http.NewServeMux()
if os.Getenv("RFW_DEVTOOLS") != "" {
mux.Handle("/debug/vars", expvar.Handler())
mux.HandleFunc("/debug/pprof/", pprof.Index)
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
}
fileServer := http.FileServerFS(fsys)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if regularFileFS(fsys, r.URL.Path) {
setWasmEncodingHeaders(w, r.URL.Path, r.URL.Query().Get("v") != "")
fileServer.ServeHTTP(w, r)
return
}
// Serve index.html only for HTML requests or bare paths, so a missing
// asset (CSS, JS, image) still returns 404 instead of HTML.
accept := r.Header.Get("Accept")
if strings.Contains(accept, "text/html") || r.URL.Path == "/" || r.URL.Path == "" {
serveIndexFS(w, r, fsys)
return
}
http.NotFound(w, r)
})
mux.Handle("/ws", runtime.Guard(websocket.Handler(func(ws *websocket.Conn) {
wsHandler(ws, runtime)
})))
return mux
}
serveIndexFS
serveIndexFS writes fsys’s index.html, the SPA entry point.
Parameters
func serveIndexFS(w http.ResponseWriter, r *http.Request, fsys fs.FS)
{
http.ServeFileFS(w, r, fsys, "index.html")
}
regularFileFS
regularFileFS reports whether name (a URL path) maps to a regular file in
fsys, the fs.FS analogue of regularFile.
Parameters
Returns
func regularFileFS(fsys fs.FS, name string) bool
{
name = strings.TrimPrefix(name, "/")
if name == "" || !fs.ValidPath(name) {
return false
}
info, err := fs.Stat(fsys, name)
return err == nil && !info.IsDir()
}
blockingJSONPayload
type blockingJSONPayload struct
Methods
Returns
func (blockingJSONPayload) MarshalJSON() ([]byte, error)
{
payload.entered <- struct{}{}
<-payload.release
return json.Marshal(payload.value)
}
Fields
| Name | Type | Description |
|---|---|---|
| entered | chan<- struct{} | |
| release | <-chan struct{} | |
| value | string |
signalingJSONPayload
type signalingJSONPayload struct
Methods
Returns
func (signalingJSONPayload) MarshalJSON() ([]byte, error)
{
payload.entered <- struct{}{}
return json.Marshal(payload.value)
}
Fields
| Name | Type | Description |
|---|---|---|
| entered | chan<- struct{} | |
| value | string |
openWriteTestSocket
Parameters
Returns
func openWriteTestSocket(t *testing.T) (*websocket.Conn, *websocket.Conn, func())
{
t.Helper()
accepted := make(chan *websocket.Conn, 1)
done := make(chan struct{})
server := httptest.NewServer(websocket.Handler(func(ws *websocket.Conn) {
accepted <- ws
<-done
}))
client, err := websocket.Dial("ws"+strings.TrimPrefix(server.URL, "http"), "", server.URL)
if err != nil {
server.Close()
t.Fatalf("dial websocket: %v", err)
}
serverSocket := <-accepted
return client, serverSocket, func() {
ForgetConnection(serverSocket)
closeTestResource(t, client)
closeTestResource(t, serverSocket)
close(done)
server.Close()
}
}
receiveOrderedMessage
Parameters
Returns
func receiveOrderedMessage(t *testing.T, socket *websocket.Conn) Outbound
{
t.Helper()
var raw []byte
if err := websocket.Message.Receive(socket, &raw); err != nil {
t.Fatalf("receive websocket message: %v", err)
}
var message Outbound
if err := json.Unmarshal(raw, &message); err != nil {
t.Fatalf("decode websocket message: %v", err)
}
return message
}
Uses
TestSendSessionOutboundSerializesSequenceAndWrite
Parameters
func TestSendSessionOutboundSerializesSequenceAndWrite(t *testing.T)
{
client, server, closeSockets := openWriteTestSocket(t)
defer closeSockets()
session := newSession("ordered-write")
BindSessionConnection(server, session)
firstEntered := make(chan struct{}, 1)
firstRelease := make(chan struct{})
secondEntered := make(chan struct{}, 1)
firstDone := make(chan struct{})
secondDone := make(chan struct{})
go func() {
SendSessionOutbound(server, session, Outbound{Payload: blockingJSONPayload{
entered: firstEntered,
release: firstRelease,
value: "first",
}})
close(firstDone)
}()
<-firstEntered
go func() {
SendSessionOutbound(server, session, Outbound{Payload: signalingJSONPayload{
entered: secondEntered,
value: "second",
}})
close(secondDone)
}()
select {
case <-secondEntered:
close(firstRelease)
<-firstDone
<-secondDone
t.Fatal("second message reached the writer before the first completed")
case <-time.After(50 * time.Millisecond):
}
close(firstRelease)
<-firstDone
<-secondDone
first := receiveOrderedMessage(t, client)
second := receiveOrderedMessage(t, client)
if first.Sequence != 1 || second.Sequence != 2 {
t.Fatalf("messages arrived out of order: first=%d second=%d", first.Sequence, second.Sequence)
}
}
TestReplaySessionDoesNotInterleaveNewMessages
Parameters
func TestReplaySessionDoesNotInterleaveNewMessages(t *testing.T)
{
client, server, closeSockets := openWriteTestSocket(t)
defer closeSockets()
session := newSession("ordered-replay", sessionOptions{replayLimit: 4})
BindSessionConnection(server, session)
firstEntered := make(chan struct{}, 1)
firstRelease := make(chan struct{})
newEntered := make(chan struct{}, 1)
session.PrepareOutbound(Outbound{Payload: blockingJSONPayload{
entered: firstEntered,
release: firstRelease,
value: "first",
}})
session.PrepareOutbound(Outbound{Payload: "second"})
replayDone := make(chan struct{})
sendDone := make(chan struct{})
go func() {
ReplaySession(server, session, 0)
close(replayDone)
}()
<-firstEntered
go func() {
SendSessionOutbound(server, session, Outbound{Payload: signalingJSONPayload{
entered: newEntered,
value: "third",
}})
close(sendDone)
}()
select {
case <-newEntered:
close(firstRelease)
<-replayDone
<-sendDone
t.Fatal("new message reached the writer during replay")
case <-time.After(50 * time.Millisecond):
}
close(firstRelease)
<-replayDone
<-sendDone
for sequence := uint64(1); sequence <= 3; sequence++ {
if message := receiveOrderedMessage(t, client); message.Sequence != sequence {
t.Fatalf("unexpected replay order: got %d want %d", message.Sequence, sequence)
}
}
}
TestStaleConnectionDoesNotConsumeSequenceAfterResume
Parameters
func TestStaleConnectionDoesNotConsumeSequenceAfterResume(t *testing.T)
{
_, oldServer, closeOld := openWriteTestSocket(t)
defer closeOld()
newClient, newServer, closeNew := openWriteTestSocket(t)
defer closeNew()
session := AllocateResumableSession(4)
defer ReleaseSession(session)
BindSessionConnection(oldServer, session)
token := session.ResumeToken()
SuspendSession(session, time.Second)
resumed, ok := ResumeSession(token)
if !ok {
t.Fatal("session did not resume")
}
BindSessionConnection(newServer, resumed)
staleEntered := make(chan struct{}, 1)
SendSessionOutbound(oldServer, resumed, Outbound{Payload: signalingJSONPayload{
entered: staleEntered,
value: "stale",
}})
select {
case <-staleEntered:
t.Fatal("stale connection reached the writer")
default:
}
SendSessionOutbound(newServer, resumed, Outbound{Payload: "current"})
message := receiveOrderedMessage(t, newClient)
if message.Sequence != 1 {
t.Fatalf("stale connection consumed a sequence: got %d want 1", message.Sequence)
}
}
TestCustomHandlerDeliveryBindsAcrossResume
Parameters
func TestCustomHandlerDeliveryBindsAcrossResume(t *testing.T)
{
oldClient, oldServer, closeOld := openWriteTestSocket(t)
defer closeOld()
newClient, newServer, closeNew := openWriteTestSocket(t)
defer closeNew()
session := AllocateResumableSession(4)
defer ReleaseSession(session)
token := session.ResumeToken()
SendSessionOutbound(oldServer, session, Outbound{Payload: "first"})
first := receiveOrderedMessage(t, oldClient)
if first.Sequence != 1 {
t.Fatalf("first sequence = %d, want 1", first.Sequence)
}
SuspendSession(session, time.Second)
resumed, ok := ResumeSession(token)
if !ok {
t.Fatal("session did not resume")
}
staleEntered := make(chan struct{}, 1)
SendSessionOutbound(oldServer, resumed, Outbound{Payload: signalingJSONPayload{
entered: staleEntered,
value: "stale",
}})
select {
case <-staleEntered:
t.Fatal("stale custom handler connection reached the writer")
default:
}
ReplaySession(newServer, resumed, 0)
replayed := receiveOrderedMessage(t, newClient)
if replayed.Sequence != first.Sequence {
t.Fatalf("replayed sequence = %d, want %d", replayed.Sequence, first.Sequence)
}
SendSessionOutbound(oldServer, resumed, Outbound{Payload: signalingJSONPayload{
entered: staleEntered,
value: "stale",
}})
select {
case <-staleEntered:
t.Fatal("stale custom handler connection reached the writer")
default:
}
SendSessionOutbound(newServer, resumed, Outbound{Payload: "second"})
second := receiveOrderedMessage(t, newClient)
if second.Sequence != 2 {
t.Fatalf("second sequence = %d, want 2", second.Sequence)
}
}
TestManagedSessionRejectsAllPriorConnections
Parameters
func TestManagedSessionRejectsAllPriorConnections(t *testing.T)
{
_, firstServer, closeFirst := openWriteTestSocket(t)
defer closeFirst()
_, secondServer, closeSecond := openWriteTestSocket(t)
defer closeSecond()
thirdClient, thirdServer, closeThird := openWriteTestSocket(t)
defer closeThird()
session := AllocateResumableSession(4)
defer ReleaseSession(session)
token := session.ResumeToken()
BindSessionConnection(firstServer, session)
SuspendSession(session, time.Second)
resumed, ok := ResumeSession(token)
if !ok {
t.Fatal("first resume failed")
}
BindSessionConnection(secondServer, resumed)
SuspendSession(resumed, time.Second)
resumed, ok = ResumeSession(token)
if !ok {
t.Fatal("second resume failed")
}
BindSessionConnection(thirdServer, resumed)
firstEntered := make(chan struct{}, 1)
SendSessionOutbound(firstServer, resumed, Outbound{Payload: signalingJSONPayload{
entered: firstEntered,
value: "first-stale",
}})
secondEntered := make(chan struct{}, 1)
SendSessionOutbound(secondServer, resumed, Outbound{Payload: signalingJSONPayload{
entered: secondEntered,
value: "second-stale",
}})
select {
case <-firstEntered:
t.Fatal("first stale connection reached the writer")
case <-secondEntered:
t.Fatal("second stale connection reached the writer")
default:
}
SendSessionOutbound(thirdServer, resumed, Outbound{Payload: "current"})
message := receiveOrderedMessage(t, thirdClient)
if message.Sequence != 1 {
t.Fatalf("stale connection consumed a sequence: got %d want 1", message.Sequence)
}
}
SSCLimits
SSCLimits bounds WebSocket resource use and action execution.
type SSCLimits struct
Fields
| Name | Type | Description |
|---|---|---|
| MaxMessageBytes | int | |
| MaxConnections | int64 | |
| MaxSessions | int | |
| MessagesPerMinute | int | |
| HandlerTimeout | time.Duration | |
| ResumeTTL | time.Duration | |
| ReplayMessages | int |
DefaultSSCLimits
DefaultSSCLimits returns the production defaults used by NewMux.
Returns
func DefaultSSCLimits() SSCLimits
{
return SSCLimits{
MaxMessageBytes: 1 << 20,
MaxConnections: 4096,
MaxSessions: 8192,
MessagesPerMinute: 600,
HandlerTimeout: 15 * time.Second,
ResumeTTL: 2 * time.Minute,
ReplayMessages: 256,
}
}
Uses
MessageAuthorizer
MessageAuthorizer can reject any decoded SSC message.
type MessageAuthorizer func(context.Context, *Session, Inbound) error
SessionInitializer
SessionInitializer copies authenticated request state into a new session.
type SessionInitializer func(*http.Request, *Session) error
MuxOption
MuxOption configures the WebSocket endpoint created by NewMux.
type MuxOption func(*WSRuntime)
WSRuntime
WSRuntime holds the guards, limits, and connection count for one endpoint.
type WSRuntime struct
Methods
Guard applies origin and upgrade authentication checks.
Parameters
Returns
func (*WSRuntime) Guard(next http.Handler) http.Handler
{
if runtime == nil {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if len(runtime.origins) > 0 {
origin := r.Header.Get("Origin")
allowed := false
for _, candidate := range runtime.origins {
if origin == candidate {
allowed = true
break
}
}
if !allowed {
http.Error(w, "origin not allowed", http.StatusForbidden)
return
}
}
if runtime.authFunc != nil && !runtime.authFunc(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
AcquireConnection reserves a connection slot.
Returns
func (*WSRuntime) AcquireConnection() bool
{
if runtime == nil {
return true
}
active := runtime.connections.Add(1)
if runtime.limits.MaxConnections > 0 && active > runtime.limits.MaxConnections {
runtime.connections.Add(-1)
return false
}
return true
}
ReleaseConnection frees a reserved connection slot.
func (*WSRuntime) ReleaseConnection()
{
if runtime != nil {
runtime.connections.Add(-1)
}
}
ConfigureConnection applies the frame-size limit.
Parameters
func (*WSRuntime) ConfigureConnection(ws *websocket.Conn)
{
if runtime != nil && runtime.limits.MaxMessageBytes > 0 {
ws.MaxPayloadBytes = runtime.limits.MaxMessageBytes
}
}
NewSession allocates and initializes a resumable session.
Parameters
Returns
func (*WSRuntime) NewSession(request *http.Request) (*Session, error)
{
replayLimit := 0
if runtime != nil {
replayLimit = runtime.limits.ReplayMessages
}
maxSessions := 0
if runtime != nil {
maxSessions = runtime.limits.MaxSessions
}
session, err := allocateSession(replayLimit, maxSessions)
if err != nil {
return nil, err
}
if runtime != nil && runtime.initialize != nil {
if err := runtime.initialize(request, session); err != nil {
ReleaseSession(session)
return nil, err
}
}
return session, nil
}
OpenSession resumes a retained session before allocating a new one.
Parameters
Returns
func (*WSRuntime) OpenSession(request *http.Request, resumeToken string) (*Session, bool, error)
{
if resumed, ok := ResumeSession(resumeToken); ok {
return resumed, true, nil
}
session, err := runtime.NewSession(request)
return session, false, err
}
Authorize validates a decoded message.
Parameters
Returns
func (*WSRuntime) Authorize(ctx context.Context, session *Session, message Inbound) error
{
if runtime == nil || runtime.authorize == nil {
return nil
}
return runtime.authorize(ctx, session, message)
}
HandlerContext returns a context bounded by HandlerTimeout.
Parameters
Returns
func (*WSRuntime) HandlerContext(parent context.Context) (context.Context, context.CancelFunc)
{
if runtime == nil || runtime.limits.HandlerTimeout <= 0 {
return context.WithCancel(parent)
}
return context.WithTimeout(parent, runtime.limits.HandlerTimeout)
}
ResumeTTL returns the configured detached-session lifetime.
Returns
func (*WSRuntime) ResumeTTL() time.Duration
{
if runtime == nil {
return 0
}
return runtime.limits.ResumeTTL
}
MessagesPerMinute returns the configured per-session message limit.
Returns
func (*WSRuntime) MessagesPerMinute() int
{
if runtime == nil {
return 0
}
return runtime.limits.MessagesPerMinute
}
DispatchAction executes a typed action within the configured handler deadline.
Parameters
Returns
func (*WSRuntime) DispatchAction(parent context.Context, session *Session, message Inbound) (any, *ActionError)
{
ctx, cancel := runtime.HandlerContext(parent)
defer cancel()
type result struct {
payload any
err *ActionError
}
resultChannel := make(chan result, 1)
go func() {
defer func() {
if recover() != nil {
resultChannel <- result{err: NewActionError("action_failed", "action failed")}
}
}()
payload, actionErr := DispatchAction(ctx, session, message.Action, message.Payload)
resultChannel <- result{payload: payload, err: actionErr}
}()
select {
case response := <-resultChannel:
return response.payload, response.err
case <-ctx.Done():
return nil, NewActionError("action_timeout", "action timed out")
}
}
Fields
| Name | Type | Description |
|---|---|---|
| authFunc | func(*http.Request) bool | |
| origins | []string | |
| authorize | MessageAuthorizer | |
| initialize | SessionInitializer | |
| limits | SSCLimits | |
| connections | atomic.Int64 |
NewWSRuntime
NewWSRuntime resolves MuxOptions into an endpoint runtime.
Parameters
Returns
func NewWSRuntime(opts ...MuxOption) *WSRuntime
{
runtime := &WSRuntime{limits: DefaultSSCLimits()}
for _, opt := range opts {
opt(runtime)
}
return runtime
}
WithAuthFunc
WithAuthFunc registers a callback invoked before the WebSocket upgrade.
Parameters
Returns
func WithAuthFunc(fn func(*http.Request) bool) MuxOption
{
return func(runtime *WSRuntime) { runtime.authFunc = fn }
}
Uses
WithOriginAllowlist
WithOriginAllowlist restricts upgrades to exact Origin matches.
Parameters
Returns
func WithOriginAllowlist(origins ...string) MuxOption
{
return func(runtime *WSRuntime) {
runtime.origins = append(runtime.origins, origins...)
}
}
Uses
WithSSCAuthorizer
WithSSCAuthorizer adds authorization after a message is decoded.
Parameters
Returns
func WithSSCAuthorizer(authorize MessageAuthorizer) MuxOption
{
return func(runtime *WSRuntime) { runtime.authorize = authorize }
}
WithSSCSessionInitializer
WithSSCSessionInitializer initializes session identity from the upgrade request.
Parameters
Returns
func WithSSCSessionInitializer(initialize SessionInitializer) MuxOption
{
return func(runtime *WSRuntime) { runtime.initialize = initialize }
}
WithSSCLimits
WithSSCLimits overrides non-zero SSC resource limits.
func WithSSCLimits(limits SSCLimits) MuxOption
{
return func(runtime *WSRuntime) {
if limits.MaxMessageBytes > 0 {
runtime.limits.MaxMessageBytes = limits.MaxMessageBytes
}
if limits.MaxConnections > 0 {
runtime.limits.MaxConnections = limits.MaxConnections
}
if limits.MaxSessions > 0 {
runtime.limits.MaxSessions = limits.MaxSessions
}
if limits.MessagesPerMinute > 0 {
runtime.limits.MessagesPerMinute = limits.MessagesPerMinute
}
if limits.HandlerTimeout > 0 {
runtime.limits.HandlerTimeout = limits.HandlerTimeout
}
if limits.ResumeTTL > 0 {
runtime.limits.ResumeTTL = limits.ResumeTTL
}
if limits.ReplayMessages > 0 {
runtime.limits.ReplayMessages = limits.ReplayMessages
}
}
}
WithoutSSCResume
WithoutSSCResume releases sessions as soon as their connection closes.
Returns
func WithoutSSCResume() MuxOption
{
return func(runtime *WSRuntime) {
runtime.limits.ResumeTTL = 0
runtime.limits.ReplayMessages = 0
}
}
Uses
GuardWS
GuardWS wraps a WebSocket handler using MuxOptions.
Parameters
Returns
func GuardWS(next http.Handler, opts ...MuxOption) http.Handler
{
return NewWSRuntime(opts...).Guard(next)
}
TestHostComponent
TestHostComponent verifies registration and handler execution.
Parameters
func TestHostComponent(t *testing.T)
{
called := false
hc := NewHostComponent("cmp", func(payload map[string]any) any {
called = true
if payload["x"] != 1 {
t.Fatalf("unexpected payload: %v", payload)
}
return "ok"
})
Register(hc)
got, ok := Get("cmp")
if !ok || got != hc {
t.Fatalf("component not registered")
}
if resp := hc.Handle(map[string]any{"x": 1}); resp != "ok" || !called {
t.Fatalf("handler not executed or wrong response: %v", resp)
}
}
TestHostComponentWithSession
Parameters
func TestHostComponentWithSession(t *testing.T)
{
hc := NewHostComponentWithSession("withSession", func(session *Session, payload map[string]any) any {
if session == nil {
t.Fatalf("session should not be nil")
}
store := session.StoreManager().NewStore("test")
store.Set("value", payload["v"])
return store.Snapshot()
})
sess := newSession("abc")
resp := hc.HandleWithSession(sess, map[string]any{"v": 42})
snap, ok := resp.(map[string]any)
if !ok {
t.Fatalf("unexpected response type %T", resp)
}
if snap["value"] != 42 {
t.Fatalf("unexpected store snapshot: %v", snap)
}
if !hc.SessionAware() {
t.Fatalf("expected session aware component")
}
if hc.StoreManager(sess) != sess.StoreManager() {
t.Fatalf("StoreManager helper mismatch")
}
}
TestLogLevel
TestLogLevel checks environment variable parsing.
Parameters
func TestLogLevel(t *testing.T)
{
t.Setenv("RFW_LOG_LEVEL", "debug")
if lvl := logLevel(); lvl.String() != "DEBUG" {
t.Fatalf("expected DEBUG level, got %s", lvl)
}
t.Setenv("RFW_LOG_LEVEL", "warn")
if lvl := logLevel(); lvl.String() != "WARN" {
t.Fatalf("expected WARN level, got %s", lvl)
}
t.Setenv("RFW_LOG_LEVEL", "")
if lvl := logLevel(); lvl.String() != "INFO" {
t.Fatalf("expected INFO level, got %s", lvl)
}
}
TestGenerateSelfSignedCert
TestGenerateSelfSignedCert ensures a certificate is generated.
Parameters
func TestGenerateSelfSignedCert(t *testing.T)
{
cert, err := generateSelfSignedCert()
if err != nil {
t.Fatalf("generateSelfSignedCert returned error: %v", err)
}
if len(cert.Certificate) == 0 {
t.Fatalf("expected certificate data")
}
}
TestSessionIsolation
Parameters
func TestSessionIsolation(t *testing.T)
{
t.Helper()
registry = make(map[string]*ServerComponent)
const componentName = "SessionHost"
Register(NewHostComponentWithSession(componentName, func(session *Session, payload map[string]any) any {
const storeKey = "counter"
storeVal, ok := session.ContextGet(storeKey)
var store *state.Store
if ok {
store = storeVal.(*state.Store)
} else {
store = session.StoreManager().NewStore("counter")
store.Set("value", 0)
session.ContextSet(storeKey, store)
}
if inc, ok := payload["increment"].(bool); ok && inc {
current, _ := store.Get("value").(int)
store.Set("value", current+1)
}
return map[string]any{"value": store.Get("value")}
}))
root := t.TempDir()
// Ensure an index exists so NewMux can serve fallback responses without error.
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("ok"), 0o600); err != nil {
t.Fatalf("write index: %v", err)
}
srv := httptest.NewServer(loggingMiddleware(NewMux(root)))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws"
type sessionConn struct {
ws *websocket.Conn
id string
idx int
}
dial := func(idx int) sessionConn {
ws, err := websocket.Dial(wsURL, "", srv.URL)
if err != nil {
t.Fatalf("dial %d: %v", idx, err)
}
init := map[string]any{
"component": componentName,
"payload": map[string]any{"init": true},
}
raw, err := json.Marshal(init)
if err != nil {
t.Fatalf("marshal init %d: %v", idx, err)
}
if err := websocket.Message.Send(ws, raw); err != nil {
t.Fatalf("send init %d: %v", idx, err)
}
var respRaw []byte
if err := websocket.Message.Receive(ws, &respRaw); err != nil {
t.Fatalf("recv init %d: %v", idx, err)
}
var resp struct {
Component string `json:"component"`
Payload map[string]any `json:"payload"`
Session string `json:"session"`
}
if err := json.Unmarshal(respRaw, &resp); err != nil {
t.Fatalf("unmarshal init %d: %v", idx, err)
}
if resp.Session == "" {
t.Fatalf("session id missing for conn %d", idx)
}
if resp.Component != componentName {
t.Fatalf("unexpected component %s", resp.Component)
}
if val, ok := resp.Payload["value"].(float64); !ok || val != 0 {
t.Fatalf("unexpected init value for conn %d: %v", idx, resp.Payload)
}
return sessionConn{ws: ws, id: resp.Session, idx: idx}
}
sessions := []sessionConn{dial(0), dial(1)}
defer func() {
for _, sc := range sessions {
closeTestResource(t, sc.ws)
}
}()
counts := []int{5, 2}
if len(counts) != len(sessions) {
t.Fatalf("mismatched counts")
}
errCh := make(chan error, len(sessions))
var wg sync.WaitGroup
for i, sc := range sessions {
wg.Add(1)
count := counts[i]
go func(sc sessionConn, target int) {
defer wg.Done()
for j := 0; j < target; j++ {
payload := map[string]any{
"component": componentName,
"payload": map[string]any{"increment": true},
}
raw, err := json.Marshal(payload)
if err != nil {
errCh <- fmt.Errorf("marshal increment idx=%d: %w", sc.idx, err)
return
}
if err := websocket.Message.Send(sc.ws, raw); err != nil {
errCh <- fmt.Errorf("send increment idx=%d: %w", sc.idx, err)
return
}
var respRaw []byte
if err := websocket.Message.Receive(sc.ws, &respRaw); err != nil {
errCh <- fmt.Errorf("recv increment idx=%d: %w", sc.idx, err)
return
}
var resp struct {
Component string `json:"component"`
Payload map[string]any `json:"payload"`
Session string `json:"session"`
}
if err := json.Unmarshal(respRaw, &resp); err != nil {
errCh <- fmt.Errorf("unmarshal increment idx=%d: %w", sc.idx, err)
return
}
if resp.Session != sc.id {
errCh <- fmt.Errorf("response session mismatch idx=%d: got %s want %s", sc.idx, resp.Session, sc.id)
return
}
}
errCh <- nil
}(sc, count)
}
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
t.Fatal(err)
}
}
for i, sc := range sessions {
sess, ok := SessionByID(sc.id)
if !ok {
t.Fatalf("session %d not found", i)
}
snap := sess.Snapshot()
module := snap["default"]
if module == nil {
t.Fatalf("session %d missing default module snapshot", i)
}
counter := module["counter"]
if counter == nil {
t.Fatalf("session %d missing counter store", i)
}
val, ok := counter["value"].(int)
if !ok {
t.Fatalf("session %d missing value entry: %v", i, counter)
}
if val != counts[i] {
t.Fatalf("session %d got value %d want %d", i, val, counts[i])
}
}
if sessions[0].id == sessions[1].id {
t.Fatal("session ids should differ")
}
}
openProtocolSocket
Parameters
Returns
func openProtocolSocket(t *testing.T, opts ...MuxOption) (*websocket.Conn, func())
{
t.Helper()
server := httptest.NewServer(NewMux(t.TempDir(), opts...))
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws"
socket, err := websocket.Dial(wsURL, "", server.URL)
if err != nil {
server.Close()
t.Fatalf("dial websocket: %v", err)
}
return socket, func() {
closeTestResource(t, socket)
server.Close()
}
}
sendProtocolMessage
Parameters
func sendProtocolMessage(t *testing.T, socket *websocket.Conn, message Inbound)
{
t.Helper()
data, err := json.Marshal(message)
if err != nil {
t.Fatalf("marshal message: %v", err)
}
if err := websocket.Message.Send(socket, data); err != nil {
t.Fatalf("send message: %v", err)
}
}
Uses
receiveProtocolMessage
Parameters
Returns
func receiveProtocolMessage(t *testing.T, socket *websocket.Conn) Outbound
{
t.Helper()
var data []byte
if err := websocket.Message.Receive(socket, &data); err != nil {
t.Fatalf("receive message: %v", err)
}
var message Outbound
if err := json.Unmarshal(data, &message); err != nil {
t.Fatalf("decode message: %v", err)
}
return message
}
Uses
TestWSTypedActionRejectsUnknownFields
Parameters
func TestWSTypedActionRejectsUnknownFields(t *testing.T)
{
type request struct {
Value int `json:"value"`
}
const action = "test.ws.strict"
if err := RegisterAction(action, func(_ context.Context, _ *Session, request request) (request, error) {
return request, nil
}); err != nil {
t.Fatalf("register action: %v", err)
}
socket, closeSocket := openProtocolSocket(t)
defer closeSocket()
sendProtocolMessage(t, socket, Inbound{
Action: action,
ID: "strict",
Sequence: 1,
Payload: map[string]any{"value": 1, "admin": true},
})
response := receiveProtocolMessage(t, socket)
if response.ID != "strict" || response.Error == nil || response.Error.Code != "invalid_request" {
t.Fatalf("unexpected strict response: %#v", response)
}
}
TestWSMessageAuthorizationRunsBeforeAction
Parameters
func TestWSMessageAuthorizationRunsBeforeAction(t *testing.T)
{
type request struct {
Value int `json:"value"`
}
const action = "test.ws.authorized"
called := false
if err := RegisterAction(action, func(_ context.Context, _ *Session, request request) (request, error) {
called = true
return request, nil
}); err != nil {
t.Fatalf("register action: %v", err)
}
socket, closeSocket := openProtocolSocket(t, WithSSCAuthorizer(func(_ context.Context, _ *Session, message Inbound) error {
if message.Action == action {
return errors.New("denied")
}
return nil
}))
defer closeSocket()
sendProtocolMessage(t, socket, Inbound{Action: action, ID: "denied", Sequence: 1})
response := receiveProtocolMessage(t, socket)
if response.Error == nil || response.Error.Code != "forbidden" || called {
t.Fatalf("authorization failed closed incorrectly: response=%#v called=%v", response, called)
}
}
TestWSRateLimitRejectsExcessMessages
Parameters
func TestWSRateLimitRejectsExcessMessages(t *testing.T)
{
type request struct{}
const action = "test.ws.rate"
if err := RegisterAction(action, func(_ context.Context, _ *Session, _ request) (string, error) {
return "ok", nil
}); err != nil {
t.Fatalf("register action: %v", err)
}
socket, closeSocket := openProtocolSocket(t, WithSSCLimits(SSCLimits{MessagesPerMinute: 1}))
defer closeSocket()
sendProtocolMessage(t, socket, Inbound{Action: action, ID: "first", Sequence: 1})
if response := receiveProtocolMessage(t, socket); response.Error != nil {
t.Fatalf("first message rejected: %#v", response)
}
sendProtocolMessage(t, socket, Inbound{Action: action, ID: "second", Sequence: 2})
response := receiveProtocolMessage(t, socket)
if response.Error == nil || response.Error.Code != "rate_limited" {
t.Fatalf("rate limit did not reject second message: %#v", response)
}
if response.Ack != 2 {
t.Fatalf("rate-limited message was not acknowledged: %#v", response)
}
}
TestWSActionTimeout
Parameters
func TestWSActionTimeout(t *testing.T)
{
type request struct{}
const action = "test.ws.timeout"
if err := RegisterAction(action, func(_ context.Context, _ *Session, _ request) (string, error) {
time.Sleep(50 * time.Millisecond)
return "late", nil
}); err != nil {
t.Fatalf("register action: %v", err)
}
socket, closeSocket := openProtocolSocket(t, WithSSCLimits(SSCLimits{HandlerTimeout: 5 * time.Millisecond}))
defer closeSocket()
sendProtocolMessage(t, socket, Inbound{Action: action, ID: "timeout", Sequence: 1})
response := receiveProtocolMessage(t, socket)
if response.Error == nil || response.Error.Code != "action_timeout" {
t.Fatalf("slow action did not time out: %#v", response)
}
}
TestWSActionPanicReturnsPublicError
Parameters
func TestWSActionPanicReturnsPublicError(t *testing.T)
{
type request struct{}
const action = "test.ws.panic"
if err := RegisterAction(action, func(_ context.Context, _ *Session, _ request) (string, error) {
panic("private handler detail")
}); err != nil {
t.Fatalf("register action: %v", err)
}
socket, closeSocket := openProtocolSocket(t)
defer closeSocket()
sendProtocolMessage(t, socket, Inbound{Action: action, ID: "panic", Sequence: 1})
response := receiveProtocolMessage(t, socket)
if response.Error == nil || response.Error.Code != "action_failed" || response.Error.Message != "action failed" {
t.Fatalf("panic details crossed the protocol: %#v", response)
}
}
TestWSRejectsOversizedFrame
Parameters
func TestWSRejectsOversizedFrame(t *testing.T)
{
socket, closeSocket := openProtocolSocket(t, WithSSCLimits(SSCLimits{MaxMessageBytes: 128}))
defer closeSocket()
data, err := json.Marshal(Inbound{
Component: "oversized",
Sequence: 1,
Payload: map[string]any{"value": strings.Repeat("x", 512)},
})
if err != nil {
t.Fatalf("marshal oversized message: %v", err)
}
if err := websocket.Message.Send(socket, data); err != nil {
t.Fatalf("send oversized message: %v", err)
}
var response []byte
if err := websocket.Message.Receive(socket, &response); err == nil {
t.Fatalf("oversized frame was accepted: %s", response)
}
}
TestWSSessionResumeReplaysUnacknowledgedResponse
Parameters
func TestWSSessionResumeReplaysUnacknowledgedResponse(t *testing.T)
{
type request struct{}
type response struct {
Count int `json:"count"`
}
const action = "test.ws.resume"
if err := RegisterAction(action, func(_ context.Context, session *Session, _ request) (response, error) {
count := 0
if stored, ok := session.ContextGet("count"); ok {
count = stored.(int)
}
count++
session.ContextSet("count", count)
return response{Count: count}, nil
}); err != nil {
t.Fatalf("register action: %v", err)
}
sessionMu.RLock()
maxSessions := len(sessions) + 1
sessionMu.RUnlock()
server := httptest.NewServer(NewMux(t.TempDir(), WithSSCLimits(SSCLimits{
ResumeTTL: time.Second,
ReplayMessages: 8,
MaxSessions: maxSessions,
})))
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws"
dial := func() *websocket.Conn {
socket, err := websocket.Dial(wsURL, "", server.URL)
if err != nil {
t.Fatalf("dial websocket: %v", err)
}
return socket
}
firstSocket := dial()
sendProtocolMessage(t, firstSocket, Inbound{Action: action, ID: "one", Sequence: 1})
first := receiveProtocolMessage(t, firstSocket)
sendProtocolMessage(t, firstSocket, Inbound{Action: action, ID: "two", Sequence: 2, Ack: first.Sequence})
second := receiveProtocolMessage(t, firstSocket)
token := second.ResumeToken
sessionID := second.Session
closeTestResource(t, firstSocket)
var (
secondSocket *websocket.Conn
replayed Outbound
current Outbound
)
deadline := time.Now().Add(time.Second)
for {
secondSocket = dial()
sendProtocolMessage(t, secondSocket, Inbound{
Action: action,
ID: "three",
Sequence: 3,
Ack: first.Sequence,
ResumeToken: token,
})
replayed = receiveProtocolMessage(t, secondSocket)
if replayed.Session == sessionID {
current = receiveProtocolMessage(t, secondSocket)
break
}
closeTestResource(t, secondSocket)
if time.Now().After(deadline) {
t.Fatalf("session did not become resumable: %#v", replayed)
}
time.Sleep(10 * time.Millisecond)
}
defer closeTestResource(t, secondSocket)
if replayed.Sequence != second.Sequence || replayed.ID != "two" {
t.Fatalf("unexpected replay: %#v", replayed)
}
if current.Session != sessionID || current.ID != "three" {
t.Fatalf("session did not resume: %#v", current)
}
payload, ok := current.Payload.(map[string]any)
if !ok || payload["count"] != float64(3) {
t.Fatalf("session state was not retained: %#v", current.Payload)
}
if session, ok := SessionByID(sessionID); ok {
ReleaseSession(session)
}
}
TestWSSessionResumeExpires
Parameters
func TestWSSessionResumeExpires(t *testing.T)
{
type request struct{}
const action = "test.ws.expiry"
if err := RegisterAction(action, func(_ context.Context, _ *Session, _ request) (string, error) {
return "ok", nil
}); err != nil {
t.Fatalf("register action: %v", err)
}
server := httptest.NewServer(NewMux(t.TempDir(), WithSSCLimits(SSCLimits{ResumeTTL: 10 * time.Millisecond})))
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws"
first, err := websocket.Dial(wsURL, "", server.URL)
if err != nil {
t.Fatalf("dial first socket: %v", err)
}
sendProtocolMessage(t, first, Inbound{Action: action, ID: "first", Sequence: 1})
response := receiveProtocolMessage(t, first)
token := response.ResumeToken
closeTestResource(t, first)
time.Sleep(40 * time.Millisecond)
second, err := websocket.Dial(wsURL, "", server.URL)
if err != nil {
t.Fatalf("dial second socket: %v", err)
}
defer closeTestResource(t, second)
sendProtocolMessage(t, second, Inbound{
Action: action,
ID: "second",
Sequence: 2,
ResumeToken: token,
})
rejected := receiveProtocolMessage(t, second)
if rejected.Control != "resume_rejected" || rejected.Error == nil {
t.Fatalf("expired session resumed: %#v", rejected)
}
}
BroadcastOption
BroadcastOption configures a broadcast call.
type BroadcastOption func(*BroadcastOptions)
BroadcastOptions
BroadcastOptions holds optional parameters for Broadcast.
type BroadcastOptions struct
Fields
| Name | Type | Description |
|---|---|---|
| Session | string |
WithSessionTarget
WithSessionTarget limits a broadcast to a specific session ID.
Parameters
Returns
func WithSessionTarget(sessionID string) BroadcastOption
{
return func(opts *BroadcastOptions) {
opts.Session = sessionID
}
}
wsHandler
Parameters
func wsHandler(ws *websocket.Conn, runtime *WSRuntime)
{
if !runtime.AcquireConnection() {
SendOutbound(ws, Outbound{Error: NewActionError("connection_limit", "connection limit reached")})
if err := ws.Close(); err != nil {
log.Printf("close rejected websocket: %v", err)
}
return
}
defer runtime.ReleaseConnection()
runtime.ConfigureConnection(ws)
var session *Session
var subscribed []string
defer func() {
connMu.Lock()
for _, name := range subscribed {
if set, ok := connections[name]; ok {
delete(set, ws)
if len(set) == 0 {
delete(connections, name)
}
}
}
connMu.Unlock()
SuspendSession(session, runtime.ResumeTTL())
ForgetConnection(ws)
if err := ws.Close(); err != nil {
log.Printf("close websocket: %v", err)
}
}()
for {
var raw []byte
if err := websocket.Message.Receive(ws, &raw); err != nil {
if err == io.EOF {
break
}
log.Printf("recv: %v", err)
return
}
var msg Inbound
if err := json.Unmarshal(raw, &msg); err != nil {
log.Printf("unmarshal: %v", err)
continue
}
if session == nil {
var resumed bool
var err error
session, resumed, err = runtime.OpenSession(ws.Request(), msg.ResumeToken)
if err != nil {
SendOutbound(ws, Outbound{Error: NewActionError("session_rejected", "session rejected")})
return
}
BindSessionConnection(ws, session)
if resumed {
ReplaySession(ws, session, msg.Ack)
} else if msg.ResumeToken != "" {
SendSessionOutbound(ws, session, Outbound{
Control: "resume_rejected",
Error: NewActionError("resume_rejected", "session could not be resumed"),
})
}
}
session.Acknowledge(msg.Ack)
if err := session.AcceptInbound(msg.Sequence); err != nil {
if errors.Is(err, ErrDuplicateMessage) {
continue
}
SendSessionOutbound(ws, session, Outbound{
ID: msg.ID,
Action: msg.Action,
Error: NewActionError("sequence_gap", "client message sequence gap"),
})
continue
}
if !session.AllowMessage(runtime.MessagesPerMinute()) {
SendSessionOutbound(ws, session, Outbound{
ID: msg.ID,
Action: msg.Action,
Error: NewActionError("rate_limited", "message rate limit exceeded"),
})
continue
}
authorizeCtx, cancelAuthorize := runtime.HandlerContext(context.Background())
authorizeErr := runtime.Authorize(authorizeCtx, session, msg)
cancelAuthorize()
if authorizeErr != nil {
SendSessionOutbound(ws, session, Outbound{
Component: msg.Component,
Action: msg.Action,
ID: msg.ID,
Error: NewActionError("forbidden", "message forbidden"),
})
continue
}
if msg.Action != "" {
payload, actionErr := runtime.DispatchAction(context.Background(), session, msg)
SendSessionOutbound(ws, session, Outbound{
Action: msg.Action,
ID: msg.ID,
Payload: payload,
Error: actionErr,
})
continue
}
if msg.Component != "" && msg.Payload != nil && msg.Payload["unsubscribe"] == true {
connMu.Lock()
if set, ok := connections[msg.Component]; ok {
delete(set, ws)
if len(set) == 0 {
delete(connections, msg.Component)
}
}
for index, name := range subscribed {
if name == msg.Component {
subscribed = append(subscribed[:index], subscribed[index+1:]...)
break
}
}
connMu.Unlock()
SendSessionOutbound(ws, session, Outbound{Component: msg.Component, Control: "unsubscribed"})
continue
}
if hc, ok := Get(msg.Component); ok {
connMu.Lock()
if _, ok := connections[msg.Component]; !ok {
connections[msg.Component] = make(map[*websocket.Conn]*Session)
}
if _, tracked := connections[msg.Component][ws]; !tracked {
connections[msg.Component][ws] = session
subscribed = append(subscribed, msg.Component)
}
connMu.Unlock()
resp := hc.HandleWithSession(session, msg.Payload)
if resp != nil {
switch v := resp.(type) {
case *InitSnapshot:
if v != nil {
SendSessionOutbound(ws, session, Outbound{Component: msg.Component, ID: msg.ID, Payload: map[string]any{"initSnapshot": v}})
}
continue
case InitSnapshot:
SendSessionOutbound(ws, session, Outbound{Component: msg.Component, ID: msg.ID, Payload: map[string]any{"initSnapshot": v}})
continue
default:
SendSessionOutbound(ws, session, Outbound{Component: msg.Component, ID: msg.ID, Payload: resp})
continue
}
}
if msg.Payload != nil && msg.Payload["init"] == true {
SendSessionOutbound(ws, session, Outbound{
Component: msg.Component,
Payload: map[string]any{"session": session.ID()},
})
continue
}
}
SendSessionOutbound(ws, session, Outbound{Control: "ack"})
}
}
Broadcast
Broadcast sends the given payload to all connections subscribed to the component name.
Parameters
func Broadcast(name string, payload any, opts ...BroadcastOption)
{
var options BroadcastOptions
for _, opt := range opts {
opt(&options)
}
// Snapshot the (conn, session) pairs under the lock: wsHandler mutates the
// connection map on subscribe/disconnect, so iterating it after releasing
// connMu races with those writes. Sends happen outside the lock.
type target struct {
ws *websocket.Conn
session *Session
}
connMu.RLock()
targets := make([]target, 0, len(connections[name]))
for ws, session := range connections[name] {
targets = append(targets, target{ws: ws, session: session})
}
connMu.RUnlock()
for _, t := range targets {
if options.Session != "" && t.session.ID() != options.Session {
continue
}
SendSessionOutbound(t.ws, t.session, Outbound{Component: name, Payload: payload})
}
}
ReplaySession
ReplaySession sends retained messages after the client’s acknowledgement.
Parameters
func ReplaySession(ws *websocket.Conn, session *Session, acknowledged uint64)
{
if session == nil {
return
}
session.outboundMu.Lock()
defer session.outboundMu.Unlock()
accepted, _ := sessionAcceptsConnection(session, ws, true)
if !accepted {
return
}
lock := connectionWriteLock(ws)
lock.Lock()
defer lock.Unlock()
messages, err := session.ReplayAfter(acknowledged)
if err != nil {
sendOutboundUnlocked(ws, session.PrepareOutbound(Outbound{
Error: NewActionError("resync_required", "message history is no longer available"),
}))
return
}
for _, message := range messages {
sendOutboundUnlocked(ws, message)
}
}
SendSessionOutbound
SendSessionOutbound assigns delivery metadata and sends a message. It binds
ws when the session has no active connection. After ResumeSession, callers
must complete the handoff with ReplaySession or BindSessionConnection before
sending.
Parameters
func SendSessionOutbound(ws *websocket.Conn, session *Session, out Outbound)
{
if session == nil {
return
}
session.outboundMu.Lock()
defer session.outboundMu.Unlock()
accepted, handoffPending := sessionAcceptsConnection(session, ws, false)
if !accepted {
if handoffPending {
logger.Debug("session outbound dropped before connection handoff", "session", session.ID())
}
return
}
lock := connectionWriteLock(ws)
lock.Lock()
defer lock.Unlock()
sendOutboundUnlocked(ws, session.PrepareOutbound(out))
}
Uses
BindSessionConnection
BindSessionConnection marks ws as the active connection for session delivery.
It binds a session without an active connection and completes the connection
handoff after ResumeSession. It does not replace an active connection.
Parameters
func BindSessionConnection(ws *websocket.Conn, session *Session)
{
if session == nil {
return
}
session.outboundMu.Lock()
accepted, _ := sessionAcceptsConnection(session, ws, true)
if accepted {
session.connectionManaged = true
}
session.outboundMu.Unlock()
}
sessionAcceptsConnection
Parameters
Returns
func sessionAcceptsConnection(session *Session, ws *websocket.Conn, handoff bool) (bool, bool)
{
if ws == nil {
return false, false
}
session.deliveryMu.Lock()
active := session.attached && !session.released
resumePending := session.resumePending
if active && resumePending && handoff {
session.resumePending = false
}
session.deliveryMu.Unlock()
if !active {
return false, false
}
if resumePending && !handoff {
return false, true
}
if session.connection == ws {
return true, false
}
if session.connection != nil || (session.connectionManaged && !handoff) {
return false, false
}
session.connection = ws
return true, false
}
SendOutbound
SendOutbound serializes writes per connection.
Parameters
func SendOutbound(ws *websocket.Conn, out Outbound)
{
lock := connectionWriteLock(ws)
lock.Lock()
defer lock.Unlock()
sendOutboundUnlocked(ws, out)
}
Uses
connectionWriteLock
Parameters
Returns
func connectionWriteLock(ws *websocket.Conn) *sync.Mutex
{
lockValue, _ := connWrites.LoadOrStore(ws, &sync.Mutex{})
return lockValue.(*sync.Mutex)
}
sendOutboundUnlocked
Parameters
func sendOutboundUnlocked(ws *websocket.Conn, out Outbound)
{
b, err := json.Marshal(out)
if err != nil {
return
}
if err := websocket.Message.Send(ws, b); err != nil {
log.Printf("send: %v", err)
}
}
Uses
ForgetConnection
ForgetConnection releases the connection write lock.
Parameters
func ForgetConnection(ws *websocket.Conn)
{
connWrites.Delete(ws)
}