host API

host

package

API reference for the host package.

F
function

TestTypedActionRejectsUnknownFields

Parameters

host/actions_test.go:9-38
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)
	}
}
F
function

TestTypedActionAuthorizationHidesInternalError

Parameters

host/actions_test.go:40-63
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)
	}
}
F
function

TestTypedFormReturnsFieldErrors

Parameters

host/actions_test.go:65-95
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)
	}
}
F
function

TestClientCanUnsubscribeFromBroadcasts

Parameters

host/broadcast_test.go:16-61
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)
	}
}
F
function

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

host/broadcast_test.go:66-118
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()
}
S
struct

Inbound

Inbound is a client-to-host SSC protocol message.

host/protocol.go:6-14
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"
S
struct

Outbound

Outbound is a host-to-client SSC protocol message.

host/protocol.go:17-28
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"
S
struct

ActionError

ActionError is a public, machine-readable action failure.

host/protocol.go:31-35
type ActionError struct

Methods

Error
Method

Returns

string
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"
F
function

NewActionError

NewActionError creates a public action error safe to return to the client.

Parameters

code
string
message
string

Returns

host/protocol.go:45-47
func NewActionError(code, message string) *ActionError

{
	return &ActionError{Code: code, Message: message}
}
F
function

TestNewMuxDebugEndpoints

Parameters

host/server_debug_test.go:11-38
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)
	}
}
F
function

testClientFS

Returns

host/server_fs_test.go:12-20
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{}")},
	}
}
F
function

TestNewMuxFSServesEmbeddedBuild

Parameters

host/server_fs_test.go:22-78
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")
	}
}
F
function

wsProbe

Parameters

origin
string

Returns

int
host/server_guard_test.go:11-30
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
}
F
function

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

host/server_guard_test.go:34-39
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)
	}
}
F
function

TestWSOriginAllowlist

Parameters

host/server_guard_test.go:41-52
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)
	}
}
F
function

TestWSAuthFunc

Parameters

host/server_guard_test.go:54-64
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)
	}
}
F
function

TestWSConnectionLimit

Parameters

host/server_guard_test.go:66-79
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()
}
F
function

TestNewMuxServesBrotliWasmWithEncoding

Parameters

host/server_wasm_test.go:14-83
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)
	}
}
F
function

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

host/server_wasm_test.go:88-121
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)
		}
	}
}
S
struct

Session

Session represents per-connection state for a WebSocket client.
It exposes an isolated StoreManager and a context bag for arbitrary data.

host/session.go:16-39
type Session struct

Methods

ID
Method

ID returns the session ID.

Returns

string
func (*Session) ID() string
{ return s.id }
ResumeToken
Method

ResumeToken returns the opaque token used to resume this session.

Returns

string
func (*Session) ResumeToken() string
{ return s.resumeToken }
StoreManager
Method

StoreManager returns the session-local store registry.

func (*Session) StoreManager() *state.StoreManager
{ return s.stores }
ContextGet
Method

ContextGet retrieves a value from the session context.

Parameters

key string

Returns

any
bool
func (*Session) ContextGet(key string) (any, bool)
{
	s.ctxMu.RLock()
	defer s.ctxMu.RUnlock()
	v, ok := s.ctx[key]
	return v, ok
}
ContextSet
Method

ContextSet stores a value in the session context.

Parameters

key string
value any
func (*Session) ContextSet(key string, value any)
{
	s.ctxMu.Lock()
	s.ctx[key] = value
	s.ctxMu.Unlock()
}
ContextDelete
Method

ContextDelete removes a value from the session context.

Parameters

key string
func (*Session) ContextDelete(key string)
{
	s.ctxMu.Lock()
	delete(s.ctx, key)
	s.ctxMu.Unlock()
}
Snapshot
Method

Snapshot returns a copy of all stores registered in this session.

Returns

map[string]map[string]map[string]any
func (*Session) Snapshot() map[string]map[string]map[string]any
{
	return s.stores.Snapshot()
}
AcceptInbound
Method

AcceptInbound validates and records an inbound sequence.

Parameters

sequence uint64

Returns

error
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
Method

AllowMessage enforces a fixed per-session message window.

Parameters

limit int

Returns

bool
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.

Parameters

out Outbound

Returns

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
Method

Acknowledge removes outbound messages confirmed by the client.

Parameters

sequence uint64
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
Method

ReplayAfter returns retained outbound messages after sequence.

Parameters

sequence uint64

Returns

error
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
S
struct

sessionOptions

host/session.go:41-44
type sessionOptions struct

Fields

Name Type Description
resumeToken string
replayLimit int
F
function

newSession

Parameters

id
string
options
...sessionOptions

Returns

host/session.go:46-59
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,
	}
}
F
function

AllocateSession

AllocateSession creates and registers a session.

Returns

host/session.go:104-107
func AllocateSession() *Session

{
	session, _ := allocateSession(0, 0)
	return session
}
F
function

AllocateResumableSession

AllocateResumableSession creates a session with ordered delivery history.

Parameters

replayLimit
int

Returns

host/session.go:110-113
func AllocateResumableSession(replayLimit int) *Session

{
	session, _ := allocateSession(replayLimit, 0)
	return session
}
F
function

allocateSession

Parameters

replayLimit
int
maxSessions
int

Returns

error
host/session.go:115-133
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
}
F
function

SuspendSession

SuspendSession detaches a connection and retains resumable state for ttl.

Parameters

session
host/session.go:136-162
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()
}
F
function

ResumeSession

ResumeSession attaches a disconnected session by opaque token.
The new socket must call ReplaySession or BindSessionConnection before sends.

Parameters

token
string

Returns

bool
host/session.go:166-189
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
}
F
function

ReleaseSession

ReleaseSession removes a session from the registry.

Parameters

session
host/session.go:192-194
func ReleaseSession(session *Session)

{
	releaseSession(session, time.Time{})
}
F
function

releaseSession

Parameters

session
expectedExpiry
host/session.go:196-227
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()
}
F
function

SessionByID

SessionByID retrieves a session for the given ID.

Parameters

id
string

Returns

bool
host/session.go:230-235
func SessionByID(id string) (*Session, bool)

{
	sessionMu.RLock()
	defer sessionMu.RUnlock()
	s, ok := sessions[id]
	return s, ok
}
F
function

generateSessionID

Returns

string
host/session.go:339-345
func generateSessionID() string

{
	buf := make([]byte, 16)
	if _, err := rand.Read(buf); err != nil {
		panic(err)
	}
	return hex.EncodeToString(buf)
}
T
type

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.

host/host_component.go:12-12
type Handler func(payload map[string]any) any
T
type

HandlerWithSession

HandlerWithSession processes inbound payloads with the associated Session.

host/host_component.go:15-15
type HandlerWithSession func(*Session, map[string]any) any
S
struct

HostComponent

HostComponent represents server-side logic backing an HTML component.

host/host_component.go:18-23
type HostComponent struct

Methods

WithInitSnapshot registers a callback that produces an InitSnapshot when a resync is requested.

Parameters

fn func(*Session, map[string]any) *InitSnapshot

Returns

func (*HostComponent) WithInitSnapshot(fn func(*Session, map[string]any) *InitSnapshot) *HostComponent
{
	hc.initSnapshot = fn
	return hc
}
Name
Method

Name returns the registered component name.

Returns

string
func (*HostComponent) Name() string
{ return hc.name }
Handle
Method

Handle executes the component's handler.

Parameters

payload map[string]any

Returns

any
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

session *Session
payload map[string]any

Returns

any
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
Method

SessionAware reports whether the component registered a session handler.

Returns

bool
func (*HostComponent) SessionAware() bool
{ return hc.sessionHandler != nil }
StoreManager
Method

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

session *Session
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
T
type

ServerComponent

ServerComponent is the concise name for HostComponent.

host/host_component.go:26-26
type ServerComponent HostComponent
S
struct

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.

host/host_component.go:33-36
type InitSnapshot struct

Fields

Name Type Description
HTML string json:"html"
Vars []string json:"vars,omitempty"
F
function

NewHostComponent

NewHostComponent registers a handler for the given component name.

Parameters

name
string
handler

Returns

host/host_component.go:39-47
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
}
F
function

NewHostComponentWithSession

NewHostComponentWithSession registers a session-aware handler.

Parameters

name
string

Returns

host/host_component.go:67-69
func NewHostComponentWithSession(name string, handler HandlerWithSession) *HostComponent

{
	return &HostComponent{name: name, sessionHandler: handler}
}
I
interface

Component

Component is the interface for struct-based host components.
Register a struct implementing Component via RegisterComponent.

host/host_component.go:104-107
type Component interface

Methods

Name
Method

Returns

string
func Name(...)
Serve
Method

Parameters

map[string]any

Returns

any
func Serve(...)
F
function

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

host/host_component.go:112-120
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()
}
F
function

Register

Register adds a HostComponent to the global registry so incoming messages
can be routed to it.

Parameters

host/host_component.go:129-133
func Register(hc *HostComponent)

{
	registryMu.Lock()
	registry[hc.name] = hc
	registryMu.Unlock()
}
F
function

Get

Get returns a registered HostComponent by name.

Parameters

name
string

Returns

host/host_component.go:136-141
func Get(name string) (*HostComponent, bool)

{
	registryMu.RLock()
	hc, ok := registry[name]
	registryMu.RUnlock()
	return hc, ok
}
F
function

validTagName

Parameters

tag
string

Returns

bool
host/html.go:12-23
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
}
F
function

isASCIILetter

Parameters

char
rune

Returns

bool
host/html.go:25-27
func isASCIILetter(char rune) bool

{
	return char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z'
}
F
function

hostVarTag

hostVarTag builds a host variable element. The value is HTML-escaped so
user-derived data cannot inject markup through the initial snapshot.

Parameters

tag
string
name
string
value
any
escape
bool

Returns

string
host/html.go:31-44
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)
}
F
function

Span

Span renders an escaped host variable in a span.

Parameters

name
string
value
any

Returns

string
host/html.go:47-49
func Span(name string, value any) string

{
	return hostVarTag("span", name, value, true)
}
F
function

Div

Div renders an escaped host variable in a div.

Parameters

name
string
value
any

Returns

string
host/html.go:52-54
func Div(name string, value any) string

{
	return hostVarTag("div", name, value, true)
}
F
function

P

P renders an escaped host variable in a paragraph.

Parameters

name
string
value
any

Returns

string
host/html.go:57-59
func P(name string, value any) string

{
	return hostVarTag("p", name, value, true)
}
F
function

Tag

Tag renders an escaped host variable with tag.

Parameters

tag
string
name
string
value
any

Returns

string
host/html.go:62-64
func Tag(tag, name string, value any) string

{
	return hostVarTag(tag, name, value, true)
}
F
function

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

tag
string
name
string
value
any

Returns

string
host/html.go:69-71
func RawTag(tag, name string, value any) string

{
	return hostVarTag(tag, name, value, false)
}
F
function

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

html
string

Returns

string
host/html.go:76-78
func Raw(html string) string

{
	return html
}
F
function

Join

Join concatenates rendered host fragments.

Parameters

parts
...string

Returns

string
host/html.go:81-87
func Join(parts ...string) string

{
	var b strings.Builder
	for _, p := range parts {
		b.WriteString(p)
	}
	return b.String()
}
F
function

TestSpan

Parameters

host/html_test.go:8-22
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)
	}
}
F
function

TestDiv

Parameters

host/html_test.go:24-32
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)
	}
}
F
function

TestP

Parameters

host/html_test.go:34-42
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)
	}
}
F
function

TestTag

Parameters

host/html_test.go:44-52
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)
	}
}
F
function

TestHelpersEscapeValues

Helper values are HTML-escaped by default so user-derived data cannot
inject markup through the initial snapshot.

Parameters

host/html_test.go:56-67
func TestHelpersEscapeValues(t *testing.T)

{
	got := Span("msg", `<img src=x onerror=alert(1)>`)
	if !strings.Contains(got, "&lt;img src=x onerror=alert(1)&gt;") {
		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="&lt;img src=x onerror=alert(1)&gt;"`) {
		t.Fatalf("expected escaped migration value: %s", got)
	}
}
F
function

TestRawTag

RawTag is the explicit trust API: the value passes through unescaped.

Parameters

host/html_test.go:70-78
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)
	}
}
F
function

TestRaw

Parameters

host/html_test.go:80-85
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)
	}
}
F
function

TestJoin

Parameters

host/html_test.go:87-95
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)
	}
}
F
function

TestHostVariableAttributesAreEscaped

Parameters

host/html_test.go:97-105
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&#34; onmouseover=&#34;alert(1)"`) {
		t.Fatalf("host variable name was not escaped: %s", got)
	}
}
F
function

TestTagRejectsInvalidName

Parameters

host/html_test.go:107-111
func TestTagRejectsInvalidName(t *testing.T)

{
	if got := Tag(`div onmouseover="alert(1)"`, "x", "test"); got != "" {
		t.Fatalf("invalid tag name was accepted: %s", got)
	}
}
S
struct

statusRecorder

host/middleware.go:11-14
type statusRecorder struct

Methods

WriteHeader
Method

Parameters

code int
func (*statusRecorder) WriteHeader(code int)
{
	r.status = code
	r.ResponseWriter.WriteHeader(code)
}
Hijack
Method

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
F
function

loggingMiddleware

Parameters

Returns

host/middleware.go:28-35
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))
	})
}
F
function

ResolveRoot

ResolveRoot resolves a content root relative to the executable when needed.

Parameters

root
string

Returns

string
host/server.go:22-33
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
}
F
function

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

root
string
opts
...MuxOption

Returns

host/server.go:38-94
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
}
F
function

ListenAndServe

ListenAndServe starts an HTTP server using NewMux to serve files and the
WebSocket endpoint.

Parameters

addr
string
root
string

Returns

error
host/server.go:98-101
func ListenAndServe(addr, root string) error

{
	logger.Info("serving HTTP", "addr", addr)
	return newHTTPServer(addr, loggingMiddleware(NewMux(root))).ListenAndServe()
}
F
function

ListenAndServeWithMux

ListenAndServeWithMux starts an HTTP server using the provided mux.

Parameters

addr
string

Returns

error
host/server.go:104-107
func ListenAndServeWithMux(addr string, mux *http.ServeMux) error

{
	logger.Info("serving HTTP", "addr", addr)
	return newHTTPServer(addr, loggingMiddleware(mux)).ListenAndServe()
}
F
function

ListenAndServeTLS

ListenAndServeTLS starts an HTTPS server using a self-signed certificate
and NewMux to serve files and the WebSocket endpoint.

Parameters

addr
string
root
string

Returns

error
host/server.go:111-120
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("", "")
}
F
function

ListenAndServeTLSWithMux

ListenAndServeTLSWithMux starts an HTTPS server using a self-signed certificate
and the provided mux, preserving any additional routes registered by callers.

Parameters

addr
string

Returns

error
host/server.go:124-133
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("", "")
}
F
function

newHTTPServer

Parameters

addr
string
handler

Returns

host/server.go:135-141
func newHTTPServer(addr string, handler http.Handler) *http.Server

{
	return &http.Server{
		Addr:              addr,
		Handler:           handler,
		ReadHeaderTimeout: 5 * time.Second,
	}
}
F
function

regularFile

Parameters

root
name
string

Returns

bool
host/server.go:143-151
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()
}
F
function

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

bool
host/server.go:156-156
func devMode() bool

{ return os.Getenv("RFW_DEV_BUILD") == "1" }
F
function

setWasmEncodingHeaders

Parameters

path
string
versioned
bool
host/server.go:158-193
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")
	}
}
F
function

generateSelfSignedCert

Returns

host/server.go:195-215
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)
}
F
function

TestSessionResumeAndReplay

Parameters

host/session_delivery_test.go:9-31
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)
}
F
function

TestSessionRejectsDuplicatesAndGaps

Parameters

host/session_delivery_test.go:33-47
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)
	}
}
F
function

TestSessionReplayReportsEvictedHistory

Parameters

host/session_delivery_test.go:49-57
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)
	}
}
F
function

TestSessionAllocationLimit

Parameters

host/session_delivery_test.go:59-72
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)
	}
}
F
function

TestWithoutSSCResumeCreatesEphemeralSession

Parameters

host/session_delivery_test.go:74-84
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)
}
F
function

TestExpiredTimerDoesNotReleaseResumedSession

Parameters

host/session_delivery_test.go:86-103
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")
	}
}
F
function

readPort

Returns

int
host/start.go:11-31
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
}
F
function

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

error
host/start.go:36-39
func StartAuto() error

{
	root := resolveRoot()
	return Start(root)
}
F
function

resolveRoot

Returns

string
host/start.go:41-55
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"
}
F
function

Start

Start launches HTTP and HTTPS servers serving files from root.
The HTTPS port is the HTTP port + 1.

Parameters

root
string

Returns

error
host/start.go:59-72
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)
}
F
function

TestReadPortOverride

Parameters

host/start_test.go:5-10
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)
	}
}
T
type

ActionHandler

ActionHandler handles a typed client action.

host/actions.go:15-15
type ActionHandler func(context.Context, *Session, Request) (Response, error)
T
type

ActionAuthorizer

ActionAuthorizer can reject an action after the request is decoded.

host/actions.go:18-18
type ActionAuthorizer func(context.Context, *Session, Request) error
S
struct

actionConfig

host/actions.go:20-22
type actionConfig struct

Fields

Name Type Description
authorize ActionAuthorizer[Request]
T
type

ActionOption

ActionOption configures a typed action.

host/actions.go:25-25
type ActionOption func(*actionConfig[Request])
F
function

WithActionAuthorizer

WithActionAuthorizer adds action-specific authorization.

Parameters

authorize
ActionAuthorizer[Request]

Returns

ActionOption[Request]
host/actions.go:28-32
func WithActionAuthorizer[Request any](authorize ActionAuthorizer[Request]) ActionOption[Request]

{
	return func(config *actionConfig[Request]) {
		config.authorize = authorize
	}
}
I
interface

registeredAction

host/actions.go:34-36
type registeredAction interface

Methods

dispatch
Method

Parameters

map[string]any

Returns

func dispatch(...)
S
struct

typedAction

host/actions.go:38-41
type typedAction struct

Fields

Name Type Description
handler ActionHandler[Request, Response]
authorize ActionAuthorizer[Request]
F
function

RegisterAction

RegisterAction registers a strict, typed SSC action.

Parameters

name
string
handler
ActionHandler[Request, Response]
opts
...ActionOption[Request]

Returns

error
host/actions.go:75-96
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
}
F
function

DispatchAction

DispatchAction decodes and executes a registered action.

Parameters

session
name
string
payload
map[string]any

Returns

host/actions.go:99-107
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)
}
F
function

decodeActionPayload

Parameters

payload
map[string]any
target
any

Returns

error
host/actions.go:109-126
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
}
F
function

publicActionError

Parameters

err
error
fallbackCode
string
fallbackMessage
string

Returns

host/actions.go:128-134
func publicActionError(err error, fallbackCode, fallbackMessage string) *ActionError

{
	var actionErr *ActionError
	if errors.As(err, &actionErr) {
		return actionErr
	}
	return NewActionError(fallbackCode, fallbackMessage)
}
T
type

FieldErrors

FieldErrors maps form field names to validation messages.

host/actions.go:137-137
type FieldErrors map[string]string
S
struct

FormResponse

FormResponse is returned by typed form actions.

host/actions.go:140-144
type FormResponse struct

Fields

Name Type Description
Data Response json:"data,omitempty"
Fields FieldErrors json:"fields,omitempty"
Valid bool json:"valid"
F
function

RegisterForm

RegisterForm registers a typed action with field validation.

Parameters

name
string
validate
func(Values) FieldErrors
submit
ActionHandler[Values, Response]
opts
...ActionOption[Values]

Returns

error
host/actions.go:147-168
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...)
}
F
function

closeTestResource

Parameters

resource
host/close_test.go:8-13
func closeTestResource(t *testing.T, resource io.Closer)

{
	t.Helper()
	if err := resource.Close(); err != nil {
		t.Errorf("close resource: %v", err)
	}
}
F
function

logLevel

Returns

host/logging.go:11-22
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
	}
}
F
function

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

fsys
opts
...MuxOption

Returns

host/server_fs.go:23-54
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
}
F
function

serveIndexFS

serveIndexFS writes fsys’s index.html, the SPA entry point.

Parameters

host/server_fs.go:57-59
func serveIndexFS(w http.ResponseWriter, r *http.Request, fsys fs.FS)

{
	http.ServeFileFS(w, r, fsys, "index.html")
}
F
function

regularFileFS

regularFileFS reports whether name (a URL path) maps to a regular file in
fsys, the fs.FS analogue of regularFile.

Parameters

fsys
name
string

Returns

bool
host/server_fs.go:63-70
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()
}
S
struct

blockingJSONPayload

host/websocket_order_test.go:15-19
type blockingJSONPayload struct

Methods

MarshalJSON
Method

Returns

[]byte
error
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
S
struct

signalingJSONPayload

host/websocket_order_test.go:27-30
type signalingJSONPayload struct

Methods

MarshalJSON
Method

Returns

[]byte
error
func (signalingJSONPayload) MarshalJSON() ([]byte, error)
{
	payload.entered <- struct{}{}
	return json.Marshal(payload.value)
}

Fields

Name Type Description
entered chan<- struct{}
value string
F
function

openWriteTestSocket

Parameters

Returns

host/websocket_order_test.go:37-58
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()
	}
}
F
function

receiveOrderedMessage

Parameters

Returns

host/websocket_order_test.go:60-71
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
}
F
function

TestSendSessionOutboundSerializesSequenceAndWrite

Parameters

host/websocket_order_test.go:73-118
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)
	}
}
F
function

TestReplaySessionDoesNotInterleaveNewMessages

Parameters

host/websocket_order_test.go:120-167
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)
		}
	}
}
F
function

TestStaleConnectionDoesNotConsumeSequenceAfterResume

Parameters

host/websocket_order_test.go:169-202
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)
	}
}
F
function

TestCustomHandlerDeliveryBindsAcrossResume

Parameters

host/websocket_order_test.go:204-256
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)
	}
}
F
function

TestManagedSessionRejectsAllPriorConnections

Parameters

host/websocket_order_test.go:258-306
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)
	}
}
S
struct

SSCLimits

SSCLimits bounds WebSocket resource use and action execution.

host/ws_runtime.go:13-21
type SSCLimits struct

Fields

Name Type Description
MaxMessageBytes int
MaxConnections int64
MaxSessions int
MessagesPerMinute int
HandlerTimeout time.Duration
ResumeTTL time.Duration
ReplayMessages int
F
function

DefaultSSCLimits

DefaultSSCLimits returns the production defaults used by NewMux.

Returns

host/ws_runtime.go:24-34
func DefaultSSCLimits() SSCLimits

{
	return SSCLimits{
		MaxMessageBytes:   1 << 20,
		MaxConnections:    4096,
		MaxSessions:       8192,
		MessagesPerMinute: 600,
		HandlerTimeout:    15 * time.Second,
		ResumeTTL:         2 * time.Minute,
		ReplayMessages:    256,
	}
}
T
type

MessageAuthorizer

MessageAuthorizer can reject any decoded SSC message.

host/ws_runtime.go:37-37
type MessageAuthorizer func(context.Context, *Session, Inbound) error
T
type

SessionInitializer

SessionInitializer copies authenticated request state into a new session.

host/ws_runtime.go:40-40
type SessionInitializer func(*http.Request, *Session) error
T
type

MuxOption

MuxOption configures the WebSocket endpoint created by NewMux.

host/ws_runtime.go:43-43
type MuxOption func(*WSRuntime)
S
struct

WSRuntime

WSRuntime holds the guards, limits, and connection count for one endpoint.

host/ws_runtime.go:46-53
type WSRuntime struct

Methods

Guard
Method

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

bool
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
Method

NewSession allocates and initializes a resumable session.

Parameters

request *http.Request

Returns

error
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
Method

OpenSession resumes a retained session before allocating a new one.

Parameters

request *http.Request
resumeToken string

Returns

bool
error
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
Method

Authorize validates a decoded message.

Parameters

session *Session
message Inbound

Returns

error
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.

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
Method

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

int
func (*WSRuntime) MessagesPerMinute() int
{
	if runtime == nil {
		return 0
	}
	return runtime.limits.MessagesPerMinute
}

DispatchAction executes a typed action within the configured handler deadline.

Parameters

session *Session
message Inbound

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
F
function

NewWSRuntime

NewWSRuntime resolves MuxOptions into an endpoint runtime.

Parameters

opts
...MuxOption

Returns

host/ws_runtime.go:56-62
func NewWSRuntime(opts ...MuxOption) *WSRuntime

{
	runtime := &WSRuntime{limits: DefaultSSCLimits()}
	for _, opt := range opts {
		opt(runtime)
	}
	return runtime
}
F
function

WithAuthFunc

WithAuthFunc registers a callback invoked before the WebSocket upgrade.

Parameters

fn
func(*http.Request) bool

Returns

host/ws_runtime.go:65-67
func WithAuthFunc(fn func(*http.Request) bool) MuxOption

{
	return func(runtime *WSRuntime) { runtime.authFunc = fn }
}
F
function

WithOriginAllowlist

WithOriginAllowlist restricts upgrades to exact Origin matches.

Parameters

origins
...string

Returns

host/ws_runtime.go:70-74
func WithOriginAllowlist(origins ...string) MuxOption

{
	return func(runtime *WSRuntime) {
		runtime.origins = append(runtime.origins, origins...)
	}
}
F
function

WithSSCAuthorizer

WithSSCAuthorizer adds authorization after a message is decoded.

Parameters

authorize

Returns

host/ws_runtime.go:77-79
func WithSSCAuthorizer(authorize MessageAuthorizer) MuxOption

{
	return func(runtime *WSRuntime) { runtime.authorize = authorize }
}
F
function

WithSSCSessionInitializer

WithSSCSessionInitializer initializes session identity from the upgrade request.

Parameters

initialize

Returns

host/ws_runtime.go:82-84
func WithSSCSessionInitializer(initialize SessionInitializer) MuxOption

{
	return func(runtime *WSRuntime) { runtime.initialize = initialize }
}
F
function

WithSSCLimits

WithSSCLimits overrides non-zero SSC resource limits.

Parameters

limits

Returns

host/ws_runtime.go:87-111
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
		}
	}
}
F
function

WithoutSSCResume

WithoutSSCResume releases sessions as soon as their connection closes.

Returns

host/ws_runtime.go:114-119
func WithoutSSCResume() MuxOption

{
	return func(runtime *WSRuntime) {
		runtime.limits.ResumeTTL = 0
		runtime.limits.ReplayMessages = 0
	}
}
F
function

GuardWS

GuardWS wraps a WebSocket handler using MuxOptions.

Parameters

opts
...MuxOption

Returns

host/ws_runtime.go:150-152
func GuardWS(next http.Handler, opts ...MuxOption) http.Handler

{
	return NewWSRuntime(opts...).Guard(next)
}
F
function

TestHostComponent

TestHostComponent verifies registration and handler execution.

Parameters

host/host_test.go:8-25
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)
	}
}
F
function

TestHostComponentWithSession

Parameters

host/host_test.go:27-52
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")
	}
}
F
function

TestLogLevel

TestLogLevel checks environment variable parsing.

Parameters

host/host_test.go:55-68
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)
	}
}
F
function

TestGenerateSelfSignedCert

TestGenerateSelfSignedCert ensures a certificate is generated.

Parameters

host/host_test.go:71-79
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")
	}
}
F
function

TestSessionIsolation

Parameters

host/session_test.go:20-191
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")
	}
}
F
function

openProtocolSocket

Parameters

opts
...MuxOption

Returns

func()
host/ssc_protocol_test.go:17-30
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()
	}
}
F
function

sendProtocolMessage

Parameters

socket
message
host/ssc_protocol_test.go:32-41
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)
	}
}
F
function

receiveProtocolMessage

Parameters

Returns

host/ssc_protocol_test.go:43-54
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
}
F
function

TestWSTypedActionRejectsUnknownFields

Parameters

host/ssc_protocol_test.go:56-79
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)
	}
}
F
function

TestWSMessageAuthorizationRunsBeforeAction

Parameters

host/ssc_protocol_test.go:81-106
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)
	}
}
F
function

TestWSRateLimitRejectsExcessMessages

Parameters

host/ssc_protocol_test.go:108-131
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)
	}
}
F
function

TestWSActionTimeout

Parameters

host/ssc_protocol_test.go:133-150
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)
	}
}
F
function

TestWSActionPanicReturnsPublicError

Parameters

host/ssc_protocol_test.go:152-168
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)
	}
}
F
function

TestWSRejectsOversizedFrame

Parameters

host/ssc_protocol_test.go:170-189
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)
	}
}
F
function

TestWSSessionResumeReplaysUnacknowledgedResponse

Parameters

host/ssc_protocol_test.go:191-276
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)
	}
}
F
function

TestWSSessionResumeExpires

Parameters

host/ssc_protocol_test.go:278-315
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)
	}
}
T
type

BroadcastOption

BroadcastOption configures a broadcast call.

host/websocket.go:15-15
type BroadcastOption func(*BroadcastOptions)
S
struct

BroadcastOptions

BroadcastOptions holds optional parameters for Broadcast.

host/websocket.go:18-20
type BroadcastOptions struct

Fields

Name Type Description
Session string
F
function

WithSessionTarget

WithSessionTarget limits a broadcast to a specific session ID.

Parameters

sessionID
string

Returns

host/websocket.go:23-27
func WithSessionTarget(sessionID string) BroadcastOption

{
	return func(opts *BroadcastOptions) {
		opts.Session = sessionID
	}
}
F
function

wsHandler

Parameters

runtime
host/websocket.go:35-193
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"})
	}
}
F
function

Broadcast

Broadcast sends the given payload to all connections subscribed to the component name.

Parameters

name
string
payload
any
opts
...BroadcastOption
host/websocket.go:196-222
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})
	}
}
F
function

ReplaySession

ReplaySession sends retained messages after the client’s acknowledgement.

Parameters

session
acknowledged
uint64
host/websocket.go:251-274
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)
	}
}
F
function

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

session
out
host/websocket.go:280-297
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))
}
F
function

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

session
host/websocket.go:302-312
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()
}
F
function

sessionAcceptsConnection

Parameters

session
handoff
bool

Returns

bool
bool
host/websocket.go:314-339
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
}
F
function

SendOutbound

SendOutbound serializes writes per connection.

Parameters

host/websocket.go:342-347
func SendOutbound(ws *websocket.Conn, out Outbound)

{
	lock := connectionWriteLock(ws)
	lock.Lock()
	defer lock.Unlock()
	sendOutboundUnlocked(ws, out)
}
F
function

connectionWriteLock

Parameters

Returns

host/websocket.go:349-352
func connectionWriteLock(ws *websocket.Conn) *sync.Mutex

{
	lockValue, _ := connWrites.LoadOrStore(ws, &sync.Mutex{})
	return lockValue.(*sync.Mutex)
}
F
function

sendOutboundUnlocked

Parameters

host/websocket.go:354-362
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)
	}
}
F
function

ForgetConnection

ForgetConnection releases the connection write lock.

Parameters

host/websocket.go:365-367
func ForgetConnection(ws *websocket.Conn)

{
	connWrites.Delete(ws)
}