host
packageAPI reference for the host
package.
Imports
(42)strings
STD
testing
STD
errors
STD
net/http
STD
time
STD
io
STD
net/http/httptest
STD
os
STD
path/filepath
STD
encoding/json
STD
fmt
STD
strconv
STD
runtime
STD
bytes
STD
context
STD
sync
STD
bufio
STD
encoding/binary
PKG
golang.org/x/net/websocket
STD
sync/atomic
STD
crypto/sha256
STD
crypto/tls
STD
log
STD
net
PKG
github.com/mirkobrombin/go-warp/v2/streambus
PKG
github.com/quic-go/quic-go
PKG
github.com/quic-go/quic-go/http3
PKG
github.com/quic-go/webtransport-go
STD
expvar
STD
io/fs
STD
net/http/pprof
STD
crypto/rand
STD
encoding/hex
INT
github.com/rfwlab/rfw/v2/state
STD
crypto/x509
STD
crypto/rsa
STD
encoding/pem
STD
math/big
STD
testing/fstest
STD
log/slog
STD
sort
STD
html
TestSpan
Parameters
func TestSpan(t *testing.T)
{
got := Span("Visit", 1)
if !strings.Contains(got, `data-host-var="Visit"`) {
t.Fatalf("missing data-host-var: %s", got)
}
if !strings.Contains(got, `data-host-expected="1"`) {
t.Fatalf("missing data-host-expected: %s", got)
}
if !strings.Contains(got, ">1</span>") {
t.Fatalf("missing value: %s", got)
}
if !strings.HasPrefix(got, "<span ") {
t.Fatalf("wrong tag: %s", got)
}
}
TestDiv
Parameters
func TestDiv(t *testing.T)
{
got := Div("message", "hello")
if !strings.Contains(got, `data-host-var="message"`) {
t.Fatalf("missing data-host-var: %s", got)
}
if !strings.Contains(got, ">hello</div>") {
t.Fatalf("missing value: %s", got)
}
}
TestP
Parameters
func TestP(t *testing.T)
{
got := P("desc", "some text")
if !strings.Contains(got, `data-host-var="desc"`) {
t.Fatalf("missing data-host-var: %s", got)
}
if !strings.Contains(got, ">some text</p>") {
t.Fatalf("missing value: %s", got)
}
}
TestTag
Parameters
func TestTag(t *testing.T)
{
got := Tag("em", "count", 42)
if !strings.Contains(got, `data-host-var="count"`) {
t.Fatalf("missing data-host-var: %s", got)
}
if !strings.Contains(got, ">42</em>") {
t.Fatalf("missing value: %s", got)
}
}
TestHelpersEscapeValues
Helper values are HTML-escaped by default so user-derived data cannot
inject markup through the initial snapshot.
Parameters
func TestHelpersEscapeValues(t *testing.T)
{
got := Span("msg", `<img src=x onerror=alert(1)>`)
if !strings.Contains(got, "<img src=x onerror=alert(1)>") {
t.Fatalf("value not escaped: %s", got)
}
if strings.Contains(got, "><img") {
t.Fatalf("markup injected: %s", got)
}
if !strings.Contains(got, `data-host-expected="<img src=x onerror=alert(1)>"`) {
t.Fatalf("expected escaped migration value: %s", got)
}
}
TestRawTag
RawTag is the explicit trust API: the value passes through unescaped.
Parameters
func TestRawTag(t *testing.T)
{
got := RawTag("div", "content", `<b>ok</b>`)
if !strings.Contains(got, "><b>ok</b></div>") {
t.Fatalf("raw value escaped: %s", got)
}
if !strings.Contains(got, `data-host-expected=""`) {
t.Fatalf("raw markup must skip text expectation: %s", got)
}
}
TestRaw
Parameters
func TestRaw(t *testing.T)
{
html := `<div class="custom">foo</div>`
if got := Raw(html); got != html {
t.Fatalf("Raw should passthrough: got %q", got)
}
}
TestJoin
Parameters
func TestJoin(t *testing.T)
{
got := Join(Span("a", 1), Div("b", 2))
if !strings.Contains(got, `data-host-var="a"`) {
t.Fatalf("missing first var: %s", got)
}
if !strings.Contains(got, `data-host-var="b"`) {
t.Fatalf("missing second var: %s", got)
}
}
TestHostVariableAttributesAreEscaped
Parameters
func TestHostVariableAttributesAreEscaped(t *testing.T)
{
got := Span(`x" onmouseover="alert(1)`, "test")
if strings.Contains(got, `data-host-var="x" onmouseover=`) {
t.Fatalf("host variable name injected an attribute: %s", got)
}
if !strings.Contains(got, `data-host-var="x" onmouseover="alert(1)"`) {
t.Fatalf("host variable name was not escaped: %s", got)
}
}
TestTagRejectsInvalidName
Parameters
func TestTagRejectsInvalidName(t *testing.T)
{
if got := Tag(`div onmouseover="alert(1)"`, "x", "test"); got != "" {
t.Fatalf("invalid tag name was accepted: %s", got)
}
}
resumeRequest
Parameters
Returns
func resumeRequest(t *testing.T, user string) *http.Request
{
t.Helper()
request, err := http.NewRequest(http.MethodGet, "/ws", nil)
if err != nil {
t.Fatalf("build request: %v", err)
}
request.Header.Set(resumeIdentityHeader, user)
return request
}
bindResumeIdentity
Parameters
Returns
func bindResumeIdentity(request *http.Request, session *Session) error
{
session.ContextSet("user", request.Header.Get(resumeIdentityHeader))
return nil
}
resumableRuntime
Parameters
Returns
func resumableRuntime(opts ...MuxOption) *WSRuntime
{
base := []MuxOption{WithSSCLimits(SSCLimits{ReplayMessages: 4, ResumeTTL: time.Minute})}
return NewWSRuntime(append(base, opts...)...)
}
detachedSession
func detachedSession(t *testing.T, runtime *WSRuntime, user string) *Session
{
t.Helper()
session, resumed, err := runtime.OpenSession(resumeRequest(t, user), "")
if err != nil {
t.Fatalf("open session: %v", err)
}
if resumed || session.ResumeToken() == "" {
t.Fatalf("expected a new resumable session, resumed=%v token=%q", resumed, session.ResumeToken())
}
SuspendSession(session, runtime.ResumeTTL())
return session
}
assertDetached
func assertDetached(t *testing.T, session *Session)
{
t.Helper()
session.deliveryMu.Lock()
attached := session.attached
expires := session.expires
session.deliveryMu.Unlock()
if attached {
t.Fatal("rejected attempt attached the retained session")
}
if expires.IsZero() {
t.Fatal("rejected attempt cleared the retention deadline")
}
}
TestResumeAuthorizerRejectsAnotherUser
A request authenticated as another user must not reattach a retained
session, and the refusal must leave that session resumable by its owner.
Parameters
func TestResumeAuthorizerRejectsAnotherUser(t *testing.T)
{
initializations := 0
runtime := resumableRuntime(
WithSSCSessionInitializer(func(request *http.Request, session *Session) error {
initializations++
return bindResumeIdentity(request, session)
}),
WithSSCResumeAuthorizer(authorizeSameUserResume),
)
owner := detachedSession(t, runtime, "alice")
defer ReleaseSession(owner)
token := owner.ResumeToken()
intruder, resumed, err := runtime.OpenSession(resumeRequest(t, "mallory"), token)
if err != nil {
t.Fatalf("open session for rejected resume: %v", err)
}
defer ReleaseSession(intruder)
if resumed || intruder == owner {
t.Fatalf("foreign request resumed the retained session: resumed=%v", resumed)
}
if user, _ := owner.ContextGet("user"); user != "alice" {
t.Fatalf("retained session identity changed to %v", user)
}
assertDetached(t, owner)
restored, resumed, err := runtime.OpenSession(resumeRequest(t, "alice"), token)
if err != nil {
t.Fatalf("open session for owner resume: %v", err)
}
if !resumed || restored != owner {
t.Fatalf("owner could not resume after a rejected attempt: resumed=%v", resumed)
}
if user, _ := restored.ContextGet("user"); user != "alice" {
t.Fatalf("resume rebound the session identity to %v", user)
}
// The two new sessions were initialized; the resume was not.
if initializations != 2 {
t.Fatalf("session initializations = %d, want 2", initializations)
}
}
TestResumeAuthorizerRejectsRevokedIdentity
Identity alone is not enough: an authorizer that consults revocation state
keeps a revoked credential from reattaching its own session.
Parameters
func TestResumeAuthorizerRejectsRevokedIdentity(t *testing.T)
{
revoked := map[string]bool{"alice": true}
runtime := resumableRuntime(
WithSSCSessionInitializer(bindResumeIdentity),
WithSSCResumeAuthorizer(func(request *http.Request, session *Session) error {
if err := authorizeSameUserResume(request, session); err != nil {
return err
}
if revoked[request.Header.Get(resumeIdentityHeader)] {
return errors.New("credential revoked")
}
return nil
}),
)
owner := detachedSession(t, runtime, "alice")
defer ReleaseSession(owner)
token := owner.ResumeToken()
replacement, resumed, err := runtime.OpenSession(resumeRequest(t, "alice"), token)
if err != nil {
t.Fatalf("open session for revoked resume: %v", err)
}
defer ReleaseSession(replacement)
if resumed || replacement == owner {
t.Fatalf("revoked identity resumed the session: resumed=%v", resumed)
}
assertDetached(t, owner)
revoked["alice"] = false
restored, resumed, err := runtime.OpenSession(resumeRequest(t, "alice"), token)
if err != nil {
t.Fatalf("open session after revocation cleared: %v", err)
}
if !resumed || restored != owner {
t.Fatalf("cleared credential could not resume: resumed=%v", resumed)
}
}
TestConcurrentResumeAttachesOneAuthorizedSession
Two upgrades presenting the same token are authorized against the same
candidate. Only one may attach it, and the loser must not be handed the
retained session under the other request’s authorization.
Parameters
func TestConcurrentResumeAttachesOneAuthorizedSession(t *testing.T)
{
authorizing := make(chan struct{}, 2)
release := make(chan struct{})
runtime := resumableRuntime(
WithSSCSessionInitializer(bindResumeIdentity),
WithSSCResumeAuthorizer(func(request *http.Request, session *Session) error {
if err := authorizeSameUserResume(request, session); err != nil {
return err
}
authorizing <- struct{}{}
<-release
return nil
}),
)
owner := detachedSession(t, runtime, "alice")
defer ReleaseSession(owner)
token := owner.ResumeToken()
type outcome struct {
session *Session
resumed bool
err error
}
requests := []*http.Request{resumeRequest(t, "alice"), resumeRequest(t, "alice")}
results := make(chan outcome, len(requests))
for _, request := range requests {
go func(request *http.Request) {
session, resumed, err := runtime.OpenSession(request, token)
results <- outcome{session: session, resumed: resumed, err: err}
}(request)
}
// Both attempts hold an authorized candidate before either commits.
<-authorizing
<-authorizing
close(release)
attached := 0
for range requests {
result := <-results
if result.err != nil {
t.Fatalf("concurrent open session: %v", result.err)
}
if result.resumed {
attached++
if result.session != owner {
t.Fatal("resume attached a session the request was not authorized for")
}
continue
}
if result.session == owner {
t.Fatal("losing attempt received the retained session")
}
ReleaseSession(result.session)
}
if attached != 1 {
t.Fatalf("resumed sessions = %d, want 1", attached)
}
}
TestResumeWithoutAuthorizerKeepsTokenBehavior
Deployments that configure no resume authorizer keep the historical
behavior, where the token alone reattaches the session.
Parameters
func TestResumeWithoutAuthorizerKeepsTokenBehavior(t *testing.T)
{
runtime := resumableRuntime()
owner := detachedSession(t, runtime, "alice")
defer ReleaseSession(owner)
restored, resumed, err := runtime.OpenSession(resumeRequest(t, "mallory"), owner.ResumeToken())
if err != nil {
t.Fatalf("open session: %v", err)
}
if !resumed || restored != owner {
t.Fatalf("token resume changed without an authorizer: resumed=%v", resumed)
}
}
TestReleasedSessionTokenStaysUnresumable
Releasing a retained session is final. Its token stops naming a candidate,
so a later upgrade presenting it never reaches the authorizer, the token-only
path cannot reattach it either, and the caller is served a new session with a
token of its own.
Parameters
func TestReleasedSessionTokenStaysUnresumable(t *testing.T)
{
handed := 0
runtime := resumableRuntime(
WithSSCSessionInitializer(bindResumeIdentity),
WithSSCResumeAuthorizer(func(request *http.Request, session *Session) error {
handed++
return authorizeSameUserResume(request, session)
}),
)
owner := detachedSession(t, runtime, "alice")
token := owner.ResumeToken()
ReleaseSession(owner)
ReleaseSession(owner)
for attempt := 0; attempt < 2; attempt++ {
session, resumed, err := runtime.OpenSession(resumeRequest(t, "alice"), token)
if err != nil {
t.Fatalf("attempt %d: open session: %v", attempt, err)
}
t.Cleanup(func() { ReleaseSession(session) })
if resumed || session == owner {
t.Fatalf("attempt %d: the released session was resumed: resumed=%v", attempt, resumed)
}
if session.ResumeToken() == "" || session.ResumeToken() == token {
t.Fatalf("attempt %d: the fresh session carries token %q", attempt, session.ResumeToken())
}
}
if handed != 0 {
t.Fatalf("the authorizer was handed a released session %d times", handed)
}
if _, registered := SessionByID(owner.ID()); registered {
t.Fatal("the released session is still registered")
}
// Detaching it again must not put its token back into circulation.
SuspendSession(owner, runtime.ResumeTTL())
if resumed, ok := ResumeSession(token); ok {
t.Fatalf("the released token reattached session %q", resumed.ID())
}
}
TestResumeAuthorizerSkippedForUnknownToken
An unknown token is refused before the authorizer runs: there is no
candidate to authorize, and the caller gets a fresh session.
Parameters
func TestResumeAuthorizerSkippedForUnknownToken(t *testing.T)
{
calls := 0
runtime := resumableRuntime(
WithSSCSessionInitializer(bindResumeIdentity),
WithSSCResumeAuthorizer(func(*http.Request, *Session) error {
calls++
return nil
}),
)
session, resumed, err := runtime.OpenSession(resumeRequest(t, "alice"), "unknown-token")
if err != nil {
t.Fatalf("open session: %v", err)
}
defer ReleaseSession(session)
if resumed || calls != 0 {
t.Fatalf("unknown token reached the authorizer: resumed=%v calls=%d", resumed, calls)
}
}
TestNewMuxServesBrotliWasmWithEncoding
Parameters
func TestNewMuxServesBrotliWasmWithEncoding(t *testing.T)
{
t.Setenv("RFW_DEVTOOLS", "")
root := t.TempDir()
clientDir := filepath.Join(root, "client")
if err := os.MkdirAll(clientDir, 0o755); err != nil {
t.Fatalf("failed to create client dir: %v", err)
}
wasmPath := filepath.Join(clientDir, "app.wasm.br")
if err := os.WriteFile(wasmPath, []byte("compressed"), 0o644); err != nil {
t.Fatalf("failed to write wasm: %v", err)
}
if err := os.WriteFile(filepath.Join(clientDir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("failed to write index: %v", err)
}
if err := os.WriteFile(filepath.Join(clientDir, "rfw_config.js"), []byte("//cfg"), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
mux := NewMux(clientDir)
srv := httptest.NewServer(mux)
defer srv.Close()
resp, err := http.Get(srv.URL + "/app.wasm.br?v=abc123")
if err != nil {
t.Fatalf("failed to get wasm: %v", err)
}
defer closeTestResource(t, resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("unexpected status: %d", resp.StatusCode)
}
if enc := resp.Header.Get("Content-Encoding"); enc != "br" {
t.Fatalf("expected Content-Encoding br, got %q", enc)
}
if ct := resp.Header.Get("Content-Type"); ct != "application/wasm" {
t.Fatalf("expected Content-Type application/wasm, got %q", ct)
}
if cache := resp.Header.Get("Cache-Control"); cache != "public, max-age=31536000, immutable" {
t.Fatalf("unexpected Cache-Control header: %q", cache)
}
if vary := resp.Header.Get("Vary"); vary != "Accept-Encoding" && vary != "Accept-Encoding, Accept-Encoding" {
// Allow duplicated value as Go's header may append values depending on environment.
t.Fatalf("unexpected Vary header: %q", vary)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read body: %v", err)
}
if string(body) != "compressed" {
t.Fatalf("unexpected body: %q", string(body))
}
resp, err = http.Get(srv.URL + "/app.wasm.br?v=")
if err != nil {
t.Fatalf("failed to get unversioned wasm: %v", err)
}
defer closeTestResource(t, resp.Body)
if cache := resp.Header.Get("Cache-Control"); cache != "no-cache" {
t.Fatalf("unexpected unversioned Cache-Control header: %q", cache)
}
resp, err = http.Get(srv.URL + "/rfw_config.js")
if err != nil {
t.Fatalf("failed to get runtime config: %v", err)
}
defer closeTestResource(t, resp.Body)
if cache := resp.Header.Get("Cache-Control"); cache != "no-cache" {
t.Fatalf("unexpected runtime config Cache-Control header: %q", cache)
}
}
TestNewMuxDevModeNoStore
TestNewMuxDevModeNoStore verifies that under rfw dev (RFW_DEV_BUILD=1) every
asset, including a versioned wasm and rfw_config.js, is served no-store so a
rebuild is never masked by an immutable cache entry.
Parameters
func TestNewMuxDevModeNoStore(t *testing.T)
{
t.Setenv("RFW_DEVTOOLS", "")
t.Setenv("RFW_DEV_BUILD", "1")
root := t.TempDir()
clientDir := filepath.Join(root, "client")
if err := os.MkdirAll(clientDir, 0o755); err != nil {
t.Fatalf("failed to create client dir: %v", err)
}
if err := os.WriteFile(filepath.Join(clientDir, "app.wasm"), []byte("wasm"), 0o644); err != nil {
t.Fatalf("failed to write wasm: %v", err)
}
if err := os.WriteFile(filepath.Join(clientDir, "rfw_config.js"), []byte("//cfg"), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
if err := os.WriteFile(filepath.Join(clientDir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("failed to write index: %v", err)
}
mux := NewMux(clientDir)
srv := httptest.NewServer(mux)
defer srv.Close()
for _, path := range []string{"/app.wasm?v=abc123", "/rfw_config.js", "/"} {
resp, err := http.Get(srv.URL + path)
if err != nil {
t.Fatalf("get %s: %v", path, err)
}
cache := resp.Header.Get("Cache-Control")
closeTestResource(t, resp.Body)
if cache != "no-store" {
t.Fatalf("dev %s: expected no-store, got %q", path, cache)
}
}
}
TestSessionResumeAndReplay
Parameters
func TestSessionResumeAndReplay(t *testing.T)
{
session := AllocateResumableSession(4)
token := session.ResumeToken()
first := session.PrepareOutbound(Outbound{Component: "Counter", Payload: map[string]any{"value": 1}})
second := session.PrepareOutbound(Outbound{Component: "Counter", Payload: map[string]any{"value": 2}})
if first.Sequence != 1 || second.Sequence != 2 || token == "" {
t.Fatalf("missing delivery metadata: first=%#v second=%#v", first, second)
}
SuspendSession(session, time.Second)
resumed, ok := ResumeSession(token)
if !ok || resumed != session {
t.Fatal("session did not resume")
}
replay, err := resumed.ReplayAfter(first.Sequence)
if err != nil {
t.Fatalf("replay: %v", err)
}
if len(replay) != 1 || replay[0].Sequence != second.Sequence {
t.Fatalf("unexpected replay: %#v", replay)
}
ReleaseSession(session)
}
TestSessionRejectsDuplicatesAndGaps
Parameters
func TestSessionRejectsDuplicatesAndGaps(t *testing.T)
{
session := newSession("ordered")
if err := session.AcceptInbound(1); err != nil {
t.Fatalf("first message: %v", err)
}
if err := session.AcceptInbound(1); !errors.Is(err, ErrDuplicateMessage) {
t.Fatalf("duplicate result: %v", err)
}
if err := session.AcceptInbound(3); !errors.Is(err, ErrSequenceGap) {
t.Fatalf("gap result: %v", err)
}
if err := session.AcceptInbound(2); err != nil {
t.Fatalf("next message: %v", err)
}
}
TestSessionReplayReportsEvictedHistory
Parameters
func TestSessionReplayReportsEvictedHistory(t *testing.T)
{
session := AllocateResumableSession(1)
defer ReleaseSession(session)
session.PrepareOutbound(Outbound{Payload: "one"})
session.PrepareOutbound(Outbound{Payload: "two"})
if _, err := session.ReplayAfter(0); !errors.Is(err, ErrReplayUnavailable) {
t.Fatalf("expected replay error, got %v", err)
}
}
TestSessionAllocationLimit
Parameters
func TestSessionAllocationLimit(t *testing.T)
{
sessionMu.RLock()
current := len(sessions)
sessionMu.RUnlock()
session, err := allocateSession(1, current+1)
if err != nil {
t.Fatalf("allocate within limit: %v", err)
}
defer ReleaseSession(session)
if _, err := allocateSession(1, current+1); !errors.Is(err, ErrSessionLimit) {
t.Fatalf("expected session limit, got %v", err)
}
}
TestWithoutSSCResumeCreatesEphemeralSession
Parameters
func TestWithoutSSCResumeCreatesEphemeralSession(t *testing.T)
{
runtime := NewWSRuntime(WithoutSSCResume())
session, err := runtime.NewSession(nil)
if err != nil {
t.Fatalf("new session: %v", err)
}
if session.ResumeToken() != "" || runtime.ResumeTTL() != 0 {
t.Fatalf("resume was not disabled: token=%q ttl=%s", session.ResumeToken(), runtime.ResumeTTL())
}
ReleaseSession(session)
}
TestExpiredTimerDoesNotReleaseResumedSession
Parameters
func TestExpiredTimerDoesNotReleaseResumedSession(t *testing.T)
{
session := AllocateResumableSession(1)
defer ReleaseSession(session)
token := session.ResumeToken()
SuspendSession(session, time.Second)
session.deliveryMu.Lock()
expectedExpiry := session.expires
session.deliveryMu.Unlock()
if _, ok := ResumeSession(token); !ok {
t.Fatal("session did not resume")
}
releaseSession(session, expectedExpiry)
if current, ok := SessionByID(session.ID()); !ok || current != session {
t.Fatal("stale expiry released the resumed session")
}
}
readPort
Returns
func readPort() int
{
if override := strings.TrimSpace(os.Getenv("RFW_HOST_PORT")); override != "" {
if p, err := strconv.Atoi(override); err == nil && p > 0 {
return p
}
}
var manifest struct {
Port int `json:"port"`
}
data, err := os.ReadFile("rfw.json")
if err != nil {
return 8080
}
if err := json.Unmarshal(data, &manifest); err != nil {
return 8080
}
if manifest.Port == 0 {
return 8080
}
return manifest.Port
}
StartAuto
StartAuto launches HTTP and HTTPS servers serving files from the default
client build directory. It resolves the root path from rfw.json or falls
back to “build/client”. This is the recommended way to start the host server.
Returns
func StartAuto() error
{
root := resolveRoot()
return Start(root)
}
resolveRoot
Returns
func resolveRoot() string
{
// Check rfw.json for build configuration.
var manifest struct {
Build struct {
Dir string `json:"dir"`
} `json:"build"`
}
if data, err := os.ReadFile("rfw.json"); err == nil {
_ = json.Unmarshal(data, &manifest)
if manifest.Build.Dir != "" {
return manifest.Build.Dir
}
}
return "build/client"
}
Start
Start launches HTTP and HTTPS servers serving files from root.
The HTTPS port is the HTTP port + 1.
Parameters
Returns
func Start(root string) error
{
port := readPort()
httpsPort := port + 1
go func() {
addr := fmt.Sprintf(":%d", port)
if err := ListenAndServe(addr, root); err != nil {
logger.Error("HTTP server error", "err", err)
}
}()
httpsAddr := fmt.Sprintf(":%d", httpsPort)
return ListenAndServeTLS(httpsAddr, root)
}
TestResolveTransportPrecedence
Parameters
func TestResolveTransportPrecedence(t *testing.T)
{
if runtime.GOOS == "js" {
t.Skip("os.Chdir is not implemented on js")
}
dir := t.TempDir()
previous, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(previous) })
if got := ResolveTransport(); got != TransportWebSocket {
t.Fatalf("default = %q", got)
}
if err := os.WriteFile(filepath.Join(dir, "rfw.json"), []byte(`{"transport":"streambus"}`), 0o600); err != nil {
t.Fatal(err)
}
if got := ResolveTransport(); got != TransportStreamBus {
t.Fatalf("manifest = %q", got)
}
t.Setenv("RFW_TRANSPORT", "ws")
if got := ResolveTransport(); got != TransportWebSocket {
t.Fatalf("environment = %q", got)
}
}
TestResolveTransportAliases
Parameters
func TestResolveTransportAliases(t *testing.T)
{
for value, want := range map[string]Transport{
"websocket": TransportWebSocket,
"WS": TransportWebSocket,
"streambus": TransportStreamBus,
"webtransport": TransportStreamBus,
"warp-streambus": TransportStreamBus,
"auto": TransportAuto,
} {
if got := normalizeTransport(value); got != want {
t.Errorf("normalizeTransport(%q) = %q, want %q", value, got, want)
}
}
}
ActionHandler
ActionHandler handles a typed client action.
type ActionHandler func(context.Context, *Session, Request) (Response, error)
ActionAuthorizer
ActionAuthorizer can reject an action after the request is decoded.
type ActionAuthorizer func(context.Context, *Session, Request) error
actionConfig
type actionConfig struct
Fields
| Name | Type | Description |
|---|---|---|
| authorize | ActionAuthorizer[Request] |
ActionOption
ActionOption configures a typed action.
type ActionOption func(*actionConfig[Request])
WithActionAuthorizer
WithActionAuthorizer adds action-specific authorization.
Parameters
Returns
func WithActionAuthorizer[Request any](authorize ActionAuthorizer[Request]) ActionOption[Request]
{
return func(config *actionConfig[Request]) {
config.authorize = authorize
}
}
registeredAction
type registeredAction interface
Methods
typedAction
type typedAction struct
Fields
| Name | Type | Description |
|---|---|---|
| handler | ActionHandler[Request, Response] | |
| authorize | ActionAuthorizer[Request] |
RegisterAction
RegisterAction registers a strict, typed SSC action.
Parameters
Returns
func RegisterAction[Request, Response any](name string, handler ActionHandler[Request, Response], opts ...ActionOption[Request]) error
{
if name == "" {
return errors.New("host: empty action name")
}
if handler == nil {
return errors.New("host: nil action handler")
}
var config actionConfig[Request]
for _, opt := range opts {
opt(&config)
}
actionRegistry.Lock()
defer actionRegistry.Unlock()
if _, exists := actionRegistry.actions[name]; exists {
return fmt.Errorf("host: action %q already registered", name)
}
actionRegistry.actions[name] = typedAction[Request, Response]{
handler: handler,
authorize: config.authorize,
}
return nil
}
DispatchAction
DispatchAction decodes and executes a registered action.
Parameters
Returns
func DispatchAction(ctx context.Context, session *Session, name string, payload map[string]any) (any, *ActionError)
{
actionRegistry.RLock()
action := actionRegistry.actions[name]
actionRegistry.RUnlock()
if action == nil {
return nil, NewActionError("action_not_found", "action not found")
}
return action.dispatch(ctx, session, payload)
}
decodeActionPayload
Parameters
Returns
func decodeActionPayload(payload map[string]any, target any) error
{
if payload == nil {
payload = map[string]any{}
}
data, err := json.Marshal(payload)
if err != nil {
return errors.New("request payload is not valid JSON")
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return fmt.Errorf("invalid request: %w", err)
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return errors.New("invalid request: multiple JSON values")
}
return nil
}
publicActionError
Parameters
Returns
func publicActionError(err error, fallbackCode, fallbackMessage string) *ActionError
{
var actionErr *ActionError
if errors.As(err, &actionErr) {
return actionErr
}
return NewActionError(fallbackCode, fallbackMessage)
}
FieldErrors
FieldErrors maps form field names to validation messages.
type FieldErrors map[string]string
FormResponse
FormResponse is returned by typed form actions.
type FormResponse struct
Fields
| Name | Type | Description |
|---|---|---|
| Data | Response | json:"data,omitempty" |
| Fields | FieldErrors | json:"fields,omitempty" |
| Valid | bool | json:"valid" |
Uses
RegisterForm
RegisterForm registers a typed action with field validation.
Parameters
Returns
func RegisterForm[Values, Response any](name string, validate func(Values) FieldErrors, submit ActionHandler[Values, Response], opts ...ActionOption[Values]) error
{
if submit == nil {
return errors.New("host: nil form handler")
}
return RegisterAction(name, func(ctx context.Context, session *Session, values Values) (FormResponse[Response], error) {
if validate != nil {
if fields := validate(values); len(fields) > 0 {
return FormResponse[Response]{Fields: fields}, nil
}
}
data, err := submit(ctx, session, values)
if err != nil {
return FormResponse[Response]{}, err
}
return FormResponse[Response]{Data: data, Valid: true}, nil
}, opts...)
}
Uses
TestFrameRoundTrip
Parameters
func TestFrameRoundTrip(t *testing.T)
{
var stream bytes.Buffer
want := []byte(`{"component":"Counter","payload":{"value":42}}`)
if err := writeFrame(&stream, want); err != nil {
t.Fatal(err)
}
got, err := readFrame(bufio.NewReader(&stream), 1024)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, want) {
t.Fatalf("frame = %q, want %q", got, want)
}
}
TestFrameRejectsPlatformOverflow
Parameters
func TestFrameRejectsPlatformOverflow(t *testing.T)
{
var prefix [binary.MaxVarintLen64]byte
n := binary.PutUvarint(prefix[:], uint64(^uint(0)>>1)+1)
if _, err := readFrame(bufio.NewReader(bytes.NewReader(prefix[:n])), 0); err == nil {
t.Fatal("readFrame accepted a length larger than int")
}
}
TestReadPortOverride
Parameters
func TestReadPortOverride(t *testing.T)
{
t.Setenv("RFW_HOST_PORT", "9095")
if got := readPort(); got != 9095 {
t.Fatalf("expected override port 9095, got %d", got)
}
}
buildDir
buildDir writes a client build with the artifacts the test names and returns
the mux serving it.
Parameters
Returns
func buildDir(t *testing.T, artifacts map[string]string) (*http.ServeMux, map[string]int)
{
t.Helper()
dir := t.TempDir()
client := filepath.Join(dir, "client")
if err := os.MkdirAll(client, 0o750); err != nil {
t.Fatalf("mkdir client: %v", err)
}
sizes := map[string]int{}
for name, body := range artifacts {
if err := os.WriteFile(filepath.Join(client, name), []byte(body), 0o600); err != nil {
t.Fatalf("write %s: %v", name, err)
}
sizes[name] = len(body)
}
if err := os.WriteFile(filepath.Join(client, "index.html"), []byte("<html></html>"), 0o600); err != nil {
t.Fatalf("write index: %v", err)
}
return NewMux(client), sizes
}
request
Parameters
Returns
func request(t *testing.T, mux *http.ServeMux, target, acceptEncoding string) *http.Response
{
t.Helper()
req := httptest.NewRequest(http.MethodGet, target, nil)
if acceptEncoding != "" {
req.Header.Set("Accept-Encoding", acceptEncoding)
}
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, req)
return recorder.Result()
}
TestNegotiationPrefersBrotli
A client that accepts brotli gets brotli from the raw URL, with the headers
that let the browser decode it and a cache entry a shared cache cannot
mis-serve.
Parameters
func TestNegotiationPrefersBrotli(t *testing.T)
{
mux, sizes := buildDir(t, allArtifacts)
resp := request(t, mux, "/app.wasm?v=abc123", "gzip, deflate, br")
defer func() { _ = resp.Body.Close() }()
if got := resp.Header.Get("Content-Encoding"); got != "br" {
t.Fatalf("Content-Encoding = %q, want br", got)
}
if got := resp.Header.Get("Content-Type"); got != "application/wasm" {
t.Fatalf("Content-Type = %q", got)
}
if got := resp.Header.Get("Content-Length"); got != strconv.Itoa(sizes["app.wasm.br"]) {
t.Fatalf("Content-Length = %q, want the compressed size %d", got, sizes["app.wasm.br"])
}
if got := resp.Header.Get("Vary"); got != "Accept-Encoding" {
t.Fatalf("Vary = %q", got)
}
if got := resp.Header.Get("Cache-Control"); got != "public, max-age=31536000, immutable" {
t.Fatalf("Cache-Control on a versioned URL = %q", got)
}
}
TestNegotiationFallsBackToGzip
A plain HTTP browser advertises gzip but not brotli, and must get gzip
rather than the raw bundle.
Parameters
func TestNegotiationFallsBackToGzip(t *testing.T)
{
mux, sizes := buildDir(t, allArtifacts)
resp := request(t, mux, "/app.wasm", "gzip, deflate")
defer func() { _ = resp.Body.Close() }()
if got := resp.Header.Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
if got := resp.Header.Get("Content-Length"); got != strconv.Itoa(sizes["app.wasm.gz"]) {
t.Fatalf("Content-Length = %q, want %d", got, sizes["app.wasm.gz"])
}
// An unversioned URL must revalidate or a release cannot replace it.
if got := resp.Header.Get("Cache-Control"); got != "no-cache" {
t.Fatalf("Cache-Control on an unversioned URL = %q", got)
}
}
TestNegotiationNeverSendsUnrequestedBrotli
Brotli is never sent to a client that did not advertise it, whatever the
build produced.
Parameters
func TestNegotiationNeverSendsUnrequestedBrotli(t *testing.T)
{
mux, _ := buildDir(t, allArtifacts)
for _, accept := range []string{"gzip", "gzip, deflate", "identity", "br;q=0, gzip"} {
resp := request(t, mux, "/app.wasm", accept)
encoding := resp.Header.Get("Content-Encoding")
_ = resp.Body.Close()
if encoding == "br" {
t.Fatalf("Accept-Encoding %q got brotli", accept)
}
}
}
TestNegotiationHonoursAnExplicitRefusal
q=0 is an explicit refusal, not a low preference.
Parameters
func TestNegotiationHonoursAnExplicitRefusal(t *testing.T)
{
mux, _ := buildDir(t, allArtifacts)
resp := request(t, mux, "/app.wasm", "br;q=0, gzip;q=0")
defer func() { _ = resp.Body.Close() }()
if got := resp.Header.Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want none", got)
}
}
TestNegotiationServesRawWithoutAnAcceptEncoding
A client that sends no Accept-Encoding gets the raw bundle, which is the
only body it is guaranteed to understand.
Parameters
func TestNegotiationServesRawWithoutAnAcceptEncoding(t *testing.T)
{
mux, sizes := buildDir(t, allArtifacts)
resp := request(t, mux, "/app.wasm", "")
defer func() { _ = resp.Body.Close() }()
if got := resp.Header.Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want none", got)
}
if got := resp.Header.Get("Content-Length"); got != strconv.Itoa(sizes["app.wasm"]) {
t.Fatalf("Content-Length = %q, want the raw size", got)
}
}
TestNegotiationFallsThroughWhenNoArtifactExists
A build with no compressed artifact still serves, so a development build
keeps working.
Parameters
func TestNegotiationFallsThroughWhenNoArtifactExists(t *testing.T)
{
mux, _ := buildDir(t, map[string]string{"app.wasm": "raw only"})
resp := request(t, mux, "/app.wasm", "gzip, br")
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
if got := resp.Header.Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q with no artifact on disk", got)
}
}
TestNegotiationSkipsAMissingBrotliArtifact
Only gzip exists, and a brotli-capable client takes it.
Parameters
func TestNegotiationSkipsAMissingBrotliArtifact(t *testing.T)
{
mux, _ := buildDir(t, map[string]string{"app.wasm": "raw", "app.wasm.gz": "gzipped"})
resp := request(t, mux, "/app.wasm", "br, gzip")
defer func() { _ = resp.Body.Close() }()
if got := resp.Header.Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
}
TestDirectArtifactURLsAreLabelled
A directly addressed artifact, which is how static hosting reaches it, is
still labelled correctly.
Parameters
func TestDirectArtifactURLsAreLabelled(t *testing.T)
{
mux, _ := buildDir(t, allArtifacts)
for path, want := range map[string]string{
"/app.wasm.br": "br",
"/app.wasm.gz": "gzip",
} {
resp := request(t, mux, path+"?v=abc123", "gzip, br")
encoding := resp.Header.Get("Content-Encoding")
contentType := resp.Header.Get("Content-Type")
cache := resp.Header.Get("Cache-Control")
vary := resp.Header.Get("Vary")
_ = resp.Body.Close()
if encoding != want {
t.Errorf("%s Content-Encoding = %q, want %q", path, encoding, want)
}
if contentType != "application/wasm" {
t.Errorf("%s Content-Type = %q", path, contentType)
}
if cache != "public, max-age=31536000, immutable" {
t.Errorf("%s Cache-Control = %q", path, cache)
}
if vary != "Accept-Encoding" {
t.Errorf("%s Vary = %q", path, vary)
}
}
}
TestClientConfigRevalidates
The pointer file has to revalidate, or a browser keeps an old version and
therefore an old bundle URL.
Parameters
func TestClientConfigRevalidates(t *testing.T)
{
mux, _ := buildDir(t, map[string]string{"app.wasm": "raw", "rfw_config.js": "window.RFW_WASM_VERSION = \"abc\";"})
resp := request(t, mux, "/rfw_config.js?v=abc123", "gzip")
defer func() { _ = resp.Body.Close() }()
if got := resp.Header.Get("Cache-Control"); got != "no-cache" {
t.Fatalf("rfw_config.js Cache-Control = %q, want no-cache", got)
}
}
TestStampedPageRevalidates
index.html carries the stamped bootstrap script tags, so a browser that
caches it keeps loading the previous release’s loader. It has to revalidate.
Parameters
func TestStampedPageRevalidates(t *testing.T)
{
mux, _ := buildDir(t, map[string]string{"app.wasm": "raw"})
for _, target := range []string{"/", "/index.html"} {
resp := request(t, mux, target, "gzip")
cache := resp.Header.Get("Cache-Control")
_ = resp.Body.Close()
if cache != "no-cache" {
t.Errorf("%s Cache-Control = %q, want no-cache", target, cache)
}
}
}
TestAcceptedEncodings
Parameters
func TestAcceptedEncodings(t *testing.T)
{
cases := map[string]map[string]float64{
"": nil,
"gzip": {"gzip": 1},
"gzip, deflate, br": {"gzip": 1, "deflate": 1, "br": 1},
"BR": {"br": 1},
" gzip ;q=0.5 , br ": {"gzip": 0.5, "br": 1},
"br;q=0, gzip": {"br": 0, "gzip": 1},
"*": {"*": 1},
"*, br;q=0": {"*": 1, "br": 0},
"identity;q=1, br;q=0": {"identity": 1, "br": 0},
"br;q=bogus, gzip": {"br": 0, "gzip": 1},
"br;q=2, gzip": {"br": 0, "gzip": 1},
}
for header, want := range cases {
got := acceptedEncodings(header)
if len(got) != len(want) {
t.Errorf("acceptedEncodings(%q) = %v, want %v", header, got, want)
continue
}
for name, quality := range want {
if got[name] != quality {
t.Errorf("acceptedEncodings(%q)[%q] = %v, want %v", header, name, got[name], quality)
}
}
}
}
TestNegotiationHonorsClientEncodingPreference
Parameters
func TestNegotiationHonorsClientEncodingPreference(t *testing.T)
{
mux, _ := buildDir(t, map[string]string{
"app.wasm": "raw",
"app.wasm.br": "brotli",
"app.wasm.gz": "gzip",
})
resp := request(t, mux, "/app.wasm", "gzip;q=1, br;q=0.1")
defer func() { _ = resp.Body.Close() }()
if got := resp.Header.Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
}
TestNegotiationUsesServerPreferenceForEqualQualities
Parameters
func TestNegotiationUsesServerPreferenceForEqualQualities(t *testing.T)
{
mux, _ := buildDir(t, map[string]string{
"app.wasm": "raw",
"app.wasm.br": "brotli",
"app.wasm.gz": "gzip",
})
resp := request(t, mux, "/app.wasm", "gzip, br")
defer func() { _ = resp.Body.Close() }()
if got := resp.Header.Get("Content-Encoding"); got != "br" {
t.Fatalf("Content-Encoding = %q, want br", got)
}
}
blockingJSONPayload
type blockingJSONPayload struct
Methods
Returns
func (blockingJSONPayload) MarshalJSON() ([]byte, error)
{
payload.entered <- struct{}{}
<-payload.release
return json.Marshal(payload.value)
}
Fields
| Name | Type | Description |
|---|---|---|
| entered | chan<- struct{} | |
| release | <-chan struct{} | |
| value | string |
signalingJSONPayload
type signalingJSONPayload struct
Methods
Returns
func (signalingJSONPayload) MarshalJSON() ([]byte, error)
{
payload.entered <- struct{}{}
return json.Marshal(payload.value)
}
Fields
| Name | Type | Description |
|---|---|---|
| entered | chan<- struct{} | |
| value | string |
openWriteTestSocket
Parameters
Returns
func openWriteTestSocket(t *testing.T) (*websocket.Conn, *websocket.Conn, func())
{
t.Helper()
accepted := make(chan *websocket.Conn, 1)
done := make(chan struct{})
server := httptest.NewServer(websocket.Handler(func(ws *websocket.Conn) {
accepted <- ws
<-done
}))
client, err := websocket.Dial("ws"+strings.TrimPrefix(server.URL, "http"), "", server.URL)
if err != nil {
server.Close()
t.Fatalf("dial websocket: %v", err)
}
serverSocket := <-accepted
return client, serverSocket, func() {
ForgetConnection(serverSocket)
closeTestResource(t, client)
closeTestResource(t, serverSocket)
close(done)
server.Close()
}
}
receiveOrderedMessage
Parameters
Returns
func receiveOrderedMessage(t *testing.T, socket *websocket.Conn) Outbound
{
t.Helper()
var raw []byte
if err := websocket.Message.Receive(socket, &raw); err != nil {
t.Fatalf("receive websocket message: %v", err)
}
var message Outbound
if err := json.Unmarshal(raw, &message); err != nil {
t.Fatalf("decode websocket message: %v", err)
}
return message
}
Uses
TestSendSessionOutboundSerializesSequenceAndWrite
Parameters
func TestSendSessionOutboundSerializesSequenceAndWrite(t *testing.T)
{
client, server, closeSockets := openWriteTestSocket(t)
defer closeSockets()
session := newSession("ordered-write")
BindSessionConnection(server, session)
firstEntered := make(chan struct{}, 1)
firstRelease := make(chan struct{})
secondEntered := make(chan struct{}, 1)
firstDone := make(chan struct{})
secondDone := make(chan struct{})
go func() {
SendSessionOutbound(server, session, Outbound{Payload: blockingJSONPayload{
entered: firstEntered,
release: firstRelease,
value: "first",
}})
close(firstDone)
}()
<-firstEntered
go func() {
SendSessionOutbound(server, session, Outbound{Payload: signalingJSONPayload{
entered: secondEntered,
value: "second",
}})
close(secondDone)
}()
select {
case <-secondEntered:
close(firstRelease)
<-firstDone
<-secondDone
t.Fatal("second message reached the writer before the first completed")
case <-time.After(50 * time.Millisecond):
}
close(firstRelease)
<-firstDone
<-secondDone
first := receiveOrderedMessage(t, client)
second := receiveOrderedMessage(t, client)
if first.Sequence != 1 || second.Sequence != 2 {
t.Fatalf("messages arrived out of order: first=%d second=%d", first.Sequence, second.Sequence)
}
}
TestReplaySessionDoesNotInterleaveNewMessages
Parameters
func TestReplaySessionDoesNotInterleaveNewMessages(t *testing.T)
{
client, server, closeSockets := openWriteTestSocket(t)
defer closeSockets()
session := newSession("ordered-replay", sessionOptions{replayLimit: 4})
BindSessionConnection(server, session)
firstEntered := make(chan struct{}, 1)
firstRelease := make(chan struct{})
newEntered := make(chan struct{}, 1)
session.PrepareOutbound(Outbound{Payload: blockingJSONPayload{
entered: firstEntered,
release: firstRelease,
value: "first",
}})
session.PrepareOutbound(Outbound{Payload: "second"})
replayDone := make(chan struct{})
sendDone := make(chan struct{})
go func() {
ReplaySession(server, session, 0)
close(replayDone)
}()
<-firstEntered
go func() {
SendSessionOutbound(server, session, Outbound{Payload: signalingJSONPayload{
entered: newEntered,
value: "third",
}})
close(sendDone)
}()
select {
case <-newEntered:
close(firstRelease)
<-replayDone
<-sendDone
t.Fatal("new message reached the writer during replay")
case <-time.After(50 * time.Millisecond):
}
close(firstRelease)
<-replayDone
<-sendDone
for sequence := uint64(1); sequence <= 3; sequence++ {
if message := receiveOrderedMessage(t, client); message.Sequence != sequence {
t.Fatalf("unexpected replay order: got %d want %d", message.Sequence, sequence)
}
}
}
TestStaleConnectionDoesNotConsumeSequenceAfterResume
Parameters
func TestStaleConnectionDoesNotConsumeSequenceAfterResume(t *testing.T)
{
_, oldServer, closeOld := openWriteTestSocket(t)
defer closeOld()
newClient, newServer, closeNew := openWriteTestSocket(t)
defer closeNew()
session := AllocateResumableSession(4)
defer ReleaseSession(session)
BindSessionConnection(oldServer, session)
token := session.ResumeToken()
SuspendSession(session, time.Second)
resumed, ok := ResumeSession(token)
if !ok {
t.Fatal("session did not resume")
}
BindSessionConnection(newServer, resumed)
staleEntered := make(chan struct{}, 1)
SendSessionOutbound(oldServer, resumed, Outbound{Payload: signalingJSONPayload{
entered: staleEntered,
value: "stale",
}})
select {
case <-staleEntered:
t.Fatal("stale connection reached the writer")
default:
}
SendSessionOutbound(newServer, resumed, Outbound{Payload: "current"})
message := receiveOrderedMessage(t, newClient)
if message.Sequence != 1 {
t.Fatalf("stale connection consumed a sequence: got %d want 1", message.Sequence)
}
}
TestCustomHandlerDeliveryBindsAcrossResume
Parameters
func TestCustomHandlerDeliveryBindsAcrossResume(t *testing.T)
{
oldClient, oldServer, closeOld := openWriteTestSocket(t)
defer closeOld()
newClient, newServer, closeNew := openWriteTestSocket(t)
defer closeNew()
session := AllocateResumableSession(4)
defer ReleaseSession(session)
token := session.ResumeToken()
SendSessionOutbound(oldServer, session, Outbound{Payload: "first"})
first := receiveOrderedMessage(t, oldClient)
if first.Sequence != 1 {
t.Fatalf("first sequence = %d, want 1", first.Sequence)
}
SuspendSession(session, time.Second)
resumed, ok := ResumeSession(token)
if !ok {
t.Fatal("session did not resume")
}
staleEntered := make(chan struct{}, 1)
SendSessionOutbound(oldServer, resumed, Outbound{Payload: signalingJSONPayload{
entered: staleEntered,
value: "stale",
}})
select {
case <-staleEntered:
t.Fatal("stale custom handler connection reached the writer")
default:
}
ReplaySession(newServer, resumed, 0)
replayed := receiveOrderedMessage(t, newClient)
if replayed.Sequence != first.Sequence {
t.Fatalf("replayed sequence = %d, want %d", replayed.Sequence, first.Sequence)
}
SendSessionOutbound(oldServer, resumed, Outbound{Payload: signalingJSONPayload{
entered: staleEntered,
value: "stale",
}})
select {
case <-staleEntered:
t.Fatal("stale custom handler connection reached the writer")
default:
}
SendSessionOutbound(newServer, resumed, Outbound{Payload: "second"})
second := receiveOrderedMessage(t, newClient)
if second.Sequence != 2 {
t.Fatalf("second sequence = %d, want 2", second.Sequence)
}
}
TestManagedSessionRejectsAllPriorConnections
Parameters
func TestManagedSessionRejectsAllPriorConnections(t *testing.T)
{
_, firstServer, closeFirst := openWriteTestSocket(t)
defer closeFirst()
_, secondServer, closeSecond := openWriteTestSocket(t)
defer closeSecond()
thirdClient, thirdServer, closeThird := openWriteTestSocket(t)
defer closeThird()
session := AllocateResumableSession(4)
defer ReleaseSession(session)
token := session.ResumeToken()
BindSessionConnection(firstServer, session)
SuspendSession(session, time.Second)
resumed, ok := ResumeSession(token)
if !ok {
t.Fatal("first resume failed")
}
BindSessionConnection(secondServer, resumed)
SuspendSession(resumed, time.Second)
resumed, ok = ResumeSession(token)
if !ok {
t.Fatal("second resume failed")
}
BindSessionConnection(thirdServer, resumed)
firstEntered := make(chan struct{}, 1)
SendSessionOutbound(firstServer, resumed, Outbound{Payload: signalingJSONPayload{
entered: firstEntered,
value: "first-stale",
}})
secondEntered := make(chan struct{}, 1)
SendSessionOutbound(secondServer, resumed, Outbound{Payload: signalingJSONPayload{
entered: secondEntered,
value: "second-stale",
}})
select {
case <-firstEntered:
t.Fatal("first stale connection reached the writer")
case <-secondEntered:
t.Fatal("second stale connection reached the writer")
default:
}
SendSessionOutbound(thirdServer, resumed, Outbound{Payload: "current"})
message := receiveOrderedMessage(t, thirdClient)
if message.Sequence != 1 {
t.Fatalf("stale connection consumed a sequence: got %d want 1", message.Sequence)
}
}
SSCLimits
SSCLimits bounds WebSocket resource use and action execution.
type SSCLimits struct
Fields
| Name | Type | Description |
|---|---|---|
| MaxMessageBytes | int | |
| MaxConnections | int64 | |
| MaxSessions | int | |
| MessagesPerMinute | int | |
| HandlerTimeout | time.Duration | |
| WriteTimeout | time.Duration | |
| OutboundQueueSize | int | |
| ResumeTTL | time.Duration | |
| ReplayMessages | int |
DefaultSSCLimits
DefaultSSCLimits returns the production defaults used by NewMux.
Returns
func DefaultSSCLimits() SSCLimits
{
return SSCLimits{
MaxMessageBytes: 1 << 20,
MaxConnections: 4096,
MaxSessions: 8192,
MessagesPerMinute: 600,
HandlerTimeout: 15 * time.Second,
WriteTimeout: 10 * time.Second,
OutboundQueueSize: 64,
ResumeTTL: 2 * time.Minute,
ReplayMessages: 256,
}
}
Uses
MessageAuthorizer
MessageAuthorizer can reject any decoded SSC message.
type MessageAuthorizer func(context.Context, *Session, Inbound) error
SessionInitializer
SessionInitializer copies authenticated request state into a new session.
type SessionInitializer func(*http.Request, *Session) error
ResumeAuthorizer
ResumeAuthorizer decides whether an upgrade request may reattach a retained
session. It receives the new request and the detached session the presented
token identifies, and returns a non-nil error to refuse the reattachment.
type ResumeAuthorizer func(*http.Request, *Session) error
MuxOption
MuxOption configures the WebSocket endpoint created by NewMux.
type MuxOption func(*WSRuntime)
WSRuntime
WSRuntime holds the guards, limits, and connection count for one endpoint.
type WSRuntime struct
Methods
Guard applies origin and upgrade authentication checks.
Parameters
Returns
func (*WSRuntime) Guard(next http.Handler) http.Handler
{
if runtime == nil {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if len(runtime.origins) > 0 {
origin := r.Header.Get("Origin")
allowed := false
for _, candidate := range runtime.origins {
if origin == candidate {
allowed = true
break
}
}
if !allowed {
http.Error(w, "origin not allowed", http.StatusForbidden)
return
}
}
if runtime.authFunc != nil && !runtime.authFunc(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
AcquireConnection reserves a connection slot.
Returns
func (*WSRuntime) AcquireConnection() bool
{
if runtime == nil {
return true
}
active := runtime.connections.Add(1)
if runtime.limits.MaxConnections > 0 && active > runtime.limits.MaxConnections {
runtime.connections.Add(-1)
return false
}
return true
}
ReleaseConnection frees a reserved connection slot.
func (*WSRuntime) ReleaseConnection()
{
if runtime != nil {
runtime.connections.Add(-1)
}
}
ConfigureConnection applies frame and outbound delivery limits.
Parameters
func (*WSRuntime) ConfigureConnection(ws *websocket.Conn)
{
if runtime == nil {
return
}
if runtime.limits.MaxMessageBytes > 0 {
ws.MaxPayloadBytes = runtime.limits.MaxMessageBytes
}
configureConnectionWriter(ws, runtime.limits.WriteTimeout, runtime.limits.OutboundQueueSize)
}
NewSession allocates and initializes a resumable session.
Parameters
Returns
func (*WSRuntime) NewSession(request *http.Request) (*Session, error)
{
replayLimit := 0
if runtime != nil {
replayLimit = runtime.limits.ReplayMessages
}
maxSessions := 0
if runtime != nil {
maxSessions = runtime.limits.MaxSessions
}
session, err := allocateSession(replayLimit, maxSessions)
if err != nil {
return nil, err
}
if runtime != nil && runtime.initialize != nil {
if err := runtime.initialize(request, session); err != nil {
ReleaseSession(session)
return nil, err
}
}
return session, nil
}
OpenSession resumes a retained session before allocating a new one. A token the request is not allowed to reattach is treated like an unresumable one: the caller gets a new session and the retained one stays available to its owner.
Parameters
Returns
func (*WSRuntime) OpenSession(request *http.Request, resumeToken string) (*Session, bool, error)
{
if resumed, ok := runtime.resumeSession(request, resumeToken); ok {
return resumed, true, nil
}
session, err := runtime.NewSession(request)
return session, false, err
}
resumeSession authorizes a reattachment before any session state changes.
Parameters
Returns
func (*WSRuntime) resumeSession(request *http.Request, resumeToken string) (*Session, bool)
{
if runtime == nil || runtime.authorizeResume == nil {
return ResumeSession(resumeToken)
}
candidate, ok := resumeCandidate(resumeToken)
if !ok {
return nil, false
}
if err := runtime.authorizeResume(request, candidate); err != nil {
return nil, false
}
if !commitResume(resumeToken, candidate) {
return nil, false
}
return candidate, true
}
Authorize validates a decoded message.
Parameters
Returns
func (*WSRuntime) Authorize(ctx context.Context, session *Session, message Inbound) error
{
if runtime == nil || runtime.authorize == nil {
return nil
}
return runtime.authorize(ctx, session, message)
}
HandlerContext returns a context bounded by HandlerTimeout.
Parameters
Returns
func (*WSRuntime) HandlerContext(parent context.Context) (context.Context, context.CancelFunc)
{
if runtime == nil || runtime.limits.HandlerTimeout <= 0 {
return context.WithCancel(parent)
}
return context.WithTimeout(parent, runtime.limits.HandlerTimeout)
}
ResumeTTL returns the configured detached-session lifetime.
Returns
func (*WSRuntime) ResumeTTL() time.Duration
{
if runtime == nil {
return 0
}
return runtime.limits.ResumeTTL
}
MessagesPerMinute returns the configured per-session message limit.
Returns
func (*WSRuntime) MessagesPerMinute() int
{
if runtime == nil {
return 0
}
return runtime.limits.MessagesPerMinute
}
DispatchAction executes a typed action within the configured handler deadline.
Parameters
Returns
func (*WSRuntime) DispatchAction(parent context.Context, session *Session, message Inbound) (any, *ActionError)
{
ctx, cancel := runtime.HandlerContext(parent)
defer cancel()
type result struct {
payload any
err *ActionError
}
resultChannel := make(chan result, 1)
go func() {
defer func() {
if recover() != nil {
resultChannel <- result{err: NewActionError("action_failed", "action failed")}
}
}()
payload, actionErr := DispatchAction(ctx, session, message.Action, message.Payload)
resultChannel <- result{payload: payload, err: actionErr}
}()
select {
case response := <-resultChannel:
return response.payload, response.err
case <-ctx.Done():
return nil, NewActionError("action_timeout", "action timed out")
}
}
Fields
| Name | Type | Description |
|---|---|---|
| authFunc | func(*http.Request) bool | |
| origins | []string | |
| authorize | MessageAuthorizer | |
| initialize | SessionInitializer | |
| authorizeResume | ResumeAuthorizer | |
| limits | SSCLimits | |
| connections | atomic.Int64 |
NewWSRuntime
NewWSRuntime resolves MuxOptions into an endpoint runtime.
Parameters
Returns
func NewWSRuntime(opts ...MuxOption) *WSRuntime
{
runtime := &WSRuntime{limits: DefaultSSCLimits()}
for _, opt := range opts {
opt(runtime)
}
return runtime
}
WithAuthFunc
WithAuthFunc registers a callback invoked before the WebSocket upgrade.
Parameters
Returns
func WithAuthFunc(fn func(*http.Request) bool) MuxOption
{
return func(runtime *WSRuntime) { runtime.authFunc = fn }
}
Uses
WithOriginAllowlist
WithOriginAllowlist restricts upgrades to exact Origin matches.
Parameters
Returns
func WithOriginAllowlist(origins ...string) MuxOption
{
return func(runtime *WSRuntime) {
runtime.origins = append(runtime.origins, origins...)
}
}
Uses
WithSSCAuthorizer
WithSSCAuthorizer adds authorization after a message is decoded.
Parameters
Returns
func WithSSCAuthorizer(authorize MessageAuthorizer) MuxOption
{
return func(runtime *WSRuntime) { runtime.authorize = authorize }
}
WithSSCSessionInitializer
WithSSCSessionInitializer initializes session identity from the upgrade request.
It runs for new sessions only; a resumed session keeps the identity bound
when it was created, so use WithSSCResumeAuthorizer to decide who may
reattach it.
Parameters
Returns
func WithSSCSessionInitializer(initialize SessionInitializer) MuxOption
{
return func(runtime *WSRuntime) { runtime.initialize = initialize }
}
WithSSCResumeAuthorizer
WithSSCResumeAuthorizer authorizes reattachment of a retained session.
The callback compares the authenticated upgrade request with the session it
would resume (identity bound by the session initializer, revocation state,
anything else the deployment tracks) and returns an error to refuse.
A refusal leaves the retained session untouched and detached, so its owner
can still resume it while its TTL lasts, and the caller is served a fresh
session exactly like an unknown token.
Without this option any client presenting a valid token resumes the session,
which is the historical behavior. An authenticated multi-user deployment must
configure a resume authorizer or disable resume with WithoutSSCResume.
Parameters
Returns
func WithSSCResumeAuthorizer(authorize ResumeAuthorizer) MuxOption
{
return func(runtime *WSRuntime) { runtime.authorizeResume = authorize }
}
WithSSCLimits
WithSSCLimits overrides non-zero SSC resource limits.
func WithSSCLimits(limits SSCLimits) MuxOption
{
return func(runtime *WSRuntime) {
if limits.MaxMessageBytes > 0 {
runtime.limits.MaxMessageBytes = limits.MaxMessageBytes
}
if limits.MaxConnections > 0 {
runtime.limits.MaxConnections = limits.MaxConnections
}
if limits.MaxSessions > 0 {
runtime.limits.MaxSessions = limits.MaxSessions
}
if limits.MessagesPerMinute > 0 {
runtime.limits.MessagesPerMinute = limits.MessagesPerMinute
}
if limits.HandlerTimeout > 0 {
runtime.limits.HandlerTimeout = limits.HandlerTimeout
}
if limits.WriteTimeout > 0 {
runtime.limits.WriteTimeout = limits.WriteTimeout
}
if limits.OutboundQueueSize > 0 {
runtime.limits.OutboundQueueSize = limits.OutboundQueueSize
}
if limits.ResumeTTL > 0 {
runtime.limits.ResumeTTL = limits.ResumeTTL
}
if limits.ReplayMessages > 0 {
runtime.limits.ReplayMessages = limits.ReplayMessages
}
}
}
WithoutSSCResume
WithoutSSCResume releases sessions as soon as their connection closes.
Returns
func WithoutSSCResume() MuxOption
{
return func(runtime *WSRuntime) {
runtime.limits.ResumeTTL = 0
runtime.limits.ReplayMessages = 0
}
}
Uses
GuardWS
GuardWS wraps a WebSocket handler using MuxOptions.
Parameters
Returns
func GuardWS(next http.Handler, opts ...MuxOption) http.Handler
{
return NewWSRuntime(opts...).Guard(next)
}
TestTypedActionRejectsUnknownFields
Parameters
func TestTypedActionRejectsUnknownFields(t *testing.T)
{
type request struct {
Name string `json:"name"`
}
type response struct {
Greeting string `json:"greeting"`
}
const name = "test.typed.strict"
if err := RegisterAction(name, func(_ context.Context, _ *Session, request request) (response, error) {
return response{Greeting: "hello " + request.Name}, nil
}); err != nil {
t.Fatalf("register action: %v", err)
}
result, actionErr := DispatchAction(context.Background(), newSession("typed"), name, map[string]any{"name": "Ada"})
if actionErr != nil {
t.Fatalf("dispatch action: %v", actionErr)
}
if result.(response).Greeting != "hello Ada" {
t.Fatalf("unexpected response: %#v", result)
}
_, actionErr = DispatchAction(context.Background(), newSession("strict"), name, map[string]any{
"name": "Ada",
"admin": true,
})
if actionErr == nil || actionErr.Code != "invalid_request" {
t.Fatalf("unknown field was accepted: %#v", actionErr)
}
}
TestTypedActionAuthorizationHidesInternalError
Parameters
func TestTypedActionAuthorizationHidesInternalError(t *testing.T)
{
type request struct {
Owner string `json:"owner"`
}
const name = "test.typed.authorized"
if err := RegisterAction(name,
func(_ context.Context, _ *Session, request request) (request, error) {
return request, nil
},
WithActionAuthorizer(func(_ context.Context, _ *Session, request request) error {
if request.Owner != "allowed" {
return errors.New("database policy detail")
}
return nil
}),
); err != nil {
t.Fatalf("register action: %v", err)
}
_, actionErr := DispatchAction(context.Background(), newSession("denied"), name, map[string]any{"owner": "denied"})
if actionErr == nil || actionErr.Code != "forbidden" || actionErr.Message != "action forbidden" {
t.Fatalf("unexpected authorization response: %#v", actionErr)
}
}
TestTypedFormReturnsFieldErrors
Parameters
func TestTypedFormReturnsFieldErrors(t *testing.T)
{
type values struct {
Email string `json:"email"`
}
type result struct {
ID int `json:"id"`
}
const name = "test.form.validation"
if err := RegisterForm(name,
func(values values) FieldErrors {
if values.Email == "" {
return FieldErrors{"email": "required"}
}
return nil
},
func(_ context.Context, _ *Session, _ values) (result, error) {
return result{ID: 7}, nil
},
); err != nil {
t.Fatalf("register form: %v", err)
}
raw, actionErr := DispatchAction(context.Background(), newSession("form"), name, map[string]any{})
if actionErr != nil {
t.Fatalf("dispatch form: %v", actionErr)
}
response := raw.(FormResponse[result])
if response.Valid || response.Fields["email"] != "required" {
t.Fatalf("unexpected form response: %#v", response)
}
}
streamBusEndpoint
type streamBusEndpoint struct
Methods
Parameters
func (*streamBusEndpoint) ServeHTTP(w http.ResponseWriter, r *http.Request)
{
e.mu.RLock()
server := e.server
e.mu.RUnlock()
if server == nil {
http.Error(w, "streambus requires HTTP/3", http.StatusUpgradeRequired)
return
}
session, err := server.Upgrade(w, r)
if err != nil {
return
}
if err := e.serveSession(r, session); err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, io.EOF) {
log.Printf("streambus: %v", err)
}
}
Parameters
Returns
func (*streamBusEndpoint) serveSession(request *http.Request, transport *wt.Session) error
{
if !e.runtime.AcquireConnection() {
_ = transport.CloseWithError(1, "connection limit reached")
return errors.New("streambus: connection limit reached")
}
defer e.runtime.ReleaseConnection()
stream, err := transport.AcceptStream(transport.Context())
if err != nil {
return err
}
connection, err := newStreamBusConnection(transport.Context(), request, transport, stream, e.bus, e.runtime.limits.MaxMessageBytes)
if err != nil {
return err
}
defer connection.Close()
return streamBusProtocolLoop(connection, e.runtime)
}
Fields
| Name | Type | Description |
|---|---|---|
| runtime | *WSRuntime | |
| bus | *streambus.InMemory | |
| mu | sync.RWMutex | |
| server | *wt.Server |
newStreamBusEndpoint
Parameters
Returns
func newStreamBusEndpoint(runtime *WSRuntime) *streamBusEndpoint
{
maximum := DefaultSSCLimits().MaxMessageBytes
if runtime != nil && runtime.limits.MaxMessageBytes > 0 {
maximum = runtime.limits.MaxMessageBytes
}
return &streamBusEndpoint{
runtime: runtime,
bus: streambus.NewInMemory(streambus.Config{
DefaultBuffer: 256, MaxBuffer: 4096, ReplayCapacity: 256,
MaxPayloadBytes: maximum,
}),
}
}
registerStreamBus
Parameters
func registerStreamBus(mux *http.ServeMux, runtime *WSRuntime)
{
if !streamBusEnabled() {
return
}
endpoint := newStreamBusEndpoint(runtime)
mux.Handle(streamBusPath, runtime.Guard(endpoint))
mux.HandleFunc("/__rfw/streambus-config", func(w http.ResponseWriter, _ *http.Request) {
hash, _ := streamCertHash.Load().(string)
port, _ := streamHTTP3Port.Load().(string)
if hash == "" || port == "" {
http.Error(w, "streambus certificate is not ready", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"certificateHash": hash, "port": port})
})
streamEndpoints.Store(mux, endpoint)
}
streamBusConnection
type streamBusConnection struct
Methods
Parameters
Returns
func (*streamBusConnection) Receive(message *Inbound) error
{
payload, err := readFrame(c.reader, c.maximum)
if err != nil {
return err
}
return json.Unmarshal(payload, message)
}
Parameters
Returns
func (*streamBusConnection) Send(out Outbound) error
{
payload, err := json.Marshal(out)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(c.transport.Context(), 5*time.Second)
defer cancel()
_, err = c.bus.Publish(ctx, streambus.Frame{
Topic: c.topic, Payload: payload, Reliability: streambus.Reliable,
Priority: streambus.PriorityInteractive,
})
return err
}
func (*streamBusConnection) writeLoop()
{
defer close(c.done)
for frame := range c.subscription.Frames() {
c.writeMu.Lock()
err := writeFrame(c.stream, frame.Payload)
c.writeMu.Unlock()
if err != nil {
_ = c.transport.CloseWithError(2, "write failed")
return
}
}
}
func (*streamBusConnection) Close()
{
c.closeOnce.Do(func() {
_ = c.transport.CloseWithError(0, "")
_ = c.subscription.Close()
c.writeMu.Lock()
_ = c.stream.Close()
c.writeMu.Unlock()
})
}
Fields
| Name | Type | Description |
|---|---|---|
| request | *http.Request | |
| transport | *wt.Session | |
| stream | *wt.Stream | |
| reader | *bufio.Reader | |
| bus | *streambus.InMemory | |
| topic | string | |
| subscription | *streambus.Subscription | |
| maximum | int | |
| writeMu | sync.Mutex | |
| closeOnce | sync.Once | |
| done | chan struct{} |
newStreamBusConnection
Parameters
Returns
func newStreamBusConnection(ctx context.Context, request *http.Request, transport *wt.Session, stream *wt.Stream, bus *streambus.InMemory, maximum int) (*streamBusConnection, error)
{
topic := fmt.Sprintf("rfw/connection/%d", streamID.Add(1))
subscription, err := bus.Subscribe(ctx, streambus.SubscribeOptions{Topic: topic, Buffer: 256, Overflow: streambus.Block})
if err != nil {
return nil, err
}
connection := &streamBusConnection{
request: request, transport: transport, stream: stream, reader: bufio.NewReader(stream),
bus: bus, topic: topic, subscription: subscription, maximum: maximum, done: make(chan struct{}),
}
go connection.writeLoop()
return connection, nil
}
streamBusProtocolLoop
Parameters
Returns
func streamBusProtocolLoop(connection *streamBusConnection, runtime *WSRuntime) error
{
var session *Session
var subscribed []string
defer func() {
streamClientsMu.Lock()
for _, name := range subscribed {
if set := streamClients[name]; set != nil {
delete(set, connection)
if len(set) == 0 {
delete(streamClients, name)
}
}
}
streamClientsMu.Unlock()
SuspendSession(session, runtime.ResumeTTL())
}()
for {
var message Inbound
if err := connection.Receive(&message); err != nil {
return err
}
if session == nil {
var resumed bool
var err error
session, resumed, err = runtime.OpenSession(connection.request, message.ResumeToken)
if err != nil {
_ = connection.Send(Outbound{Error: NewActionError("session_rejected", "session rejected")})
return err
}
if !bindStreamBusConnection(connection, session, true) {
_ = connection.Send(Outbound{Error: NewActionError("session_rejected", "session already has an active connection")})
return errors.New("streambus: session already has an active connection")
}
if resumed {
replayStreamBus(connection, session, message.Ack)
} else if message.ResumeToken != "" {
sendStreamBusSession(connection, session, Outbound{Control: "resume_rejected", Error: NewActionError("resume_rejected", "session could not be resumed")})
}
}
session.Acknowledge(message.Ack)
if message.Control == "ping" {
sendStreamBusSession(connection, session, Outbound{Control: "pong"})
continue
}
if err := session.AcceptInbound(message.Sequence); err != nil {
if errors.Is(err, ErrDuplicateMessage) {
continue
}
sendStreamBusSession(connection, session, Outbound{ID: message.ID, Action: message.Action, Error: NewActionError("sequence_gap", "client message sequence gap")})
continue
}
if !session.AllowMessage(runtime.MessagesPerMinute()) {
sendStreamBusSession(connection, session, Outbound{ID: message.ID, Action: message.Action, Error: NewActionError("rate_limited", "message rate limit exceeded")})
continue
}
authorizeCtx, cancel := runtime.HandlerContext(context.Background())
authorizeErr := runtime.Authorize(authorizeCtx, session, message)
cancel()
if authorizeErr != nil {
sendStreamBusSession(connection, session, Outbound{Component: message.Component, Action: message.Action, ID: message.ID, Error: NewActionError("forbidden", "message forbidden")})
continue
}
if message.Action != "" {
payload, actionErr := runtime.DispatchAction(context.Background(), session, message)
sendStreamBusSession(connection, session, Outbound{Action: message.Action, ID: message.ID, Payload: payload, Error: actionErr})
continue
}
if message.Component != "" && message.Payload != nil && message.Payload["unsubscribe"] == true {
streamClientsMu.Lock()
if set := streamClients[message.Component]; set != nil {
delete(set, connection)
if len(set) == 0 {
delete(streamClients, message.Component)
}
}
for index, name := range subscribed {
if name == message.Component {
subscribed = append(subscribed[:index], subscribed[index+1:]...)
break
}
}
streamClientsMu.Unlock()
sendStreamBusSession(connection, session, Outbound{Component: message.Component, Control: "unsubscribed"})
continue
}
if component, ok := Get(message.Component); ok {
streamClientsMu.Lock()
if streamClients[message.Component] == nil {
streamClients[message.Component] = make(map[*streamBusConnection]*Session)
}
if _, tracked := streamClients[message.Component][connection]; !tracked {
streamClients[message.Component][connection] = session
subscribed = append(subscribed, message.Component)
}
streamClientsMu.Unlock()
response := component.HandleWithSession(session, message.Payload)
if response != nil {
switch value := response.(type) {
case *InitSnapshot:
if value != nil {
sendStreamBusSession(connection, session, Outbound{Component: message.Component, ID: message.ID, Payload: map[string]any{"initSnapshot": value}})
}
case InitSnapshot:
sendStreamBusSession(connection, session, Outbound{Component: message.Component, ID: message.ID, Payload: map[string]any{"initSnapshot": value}})
default:
sendStreamBusSession(connection, session, Outbound{Component: message.Component, ID: message.ID, Payload: response})
}
continue
}
if message.Payload != nil && message.Payload["init"] == true {
sendStreamBusSession(connection, session, Outbound{Component: message.Component, Payload: map[string]any{"session": session.ID()}})
continue
}
}
sendStreamBusSession(connection, session, Outbound{Control: "ack"})
}
}
bindStreamBusConnection
Parameters
Returns
func bindStreamBusConnection(connection *streamBusConnection, session *Session, handoff bool) bool
{
if session == nil || connection == nil {
return false
}
session.outboundMu.Lock()
defer session.outboundMu.Unlock()
session.deliveryMu.Lock()
active := session.attached && !session.released
resumePending := session.resumePending
if active && resumePending && handoff {
session.resumePending = false
}
session.deliveryMu.Unlock()
if !active || (resumePending && !handoff) {
return false
}
if session.streamConnection == connection {
return true
}
if session.streamConnection != nil || session.connection != nil || (session.connectionManaged && !handoff) {
return false
}
session.streamConnection = connection
session.connection = nil
session.connectionManaged = true
return true
}
sendStreamBusSession
Parameters
func sendStreamBusSession(connection *streamBusConnection, session *Session, out Outbound)
{
if session == nil {
return
}
session.outboundMu.Lock()
defer session.outboundMu.Unlock()
if session.streamConnection != connection {
return
}
_ = connection.Send(session.PrepareOutbound(out))
}
Uses
replayStreamBus
Parameters
func replayStreamBus(connection *streamBusConnection, session *Session, acknowledged uint64)
{
session.outboundMu.Lock()
defer session.outboundMu.Unlock()
if session.streamConnection != connection {
return
}
messages, err := session.ReplayAfter(acknowledged)
if err != nil {
_ = connection.Send(session.PrepareOutbound(Outbound{Error: NewActionError("resync_required", "message history is no longer available")}))
return
}
for _, message := range messages {
_ = connection.Send(message)
}
}
startStreamBusHTTP3
Parameters
func startStreamBusHTTP3(addr string, mux *http.ServeMux, tlsConfig *tls.Config)
{
value, ok := streamEndpoints.Load(mux)
if !ok {
return
}
endpoint := value.(*streamBusEndpoint)
port := strings.TrimPrefix(addr, ":")
if _, parsedPort, err := net.SplitHostPort(addr); err == nil {
port = parsedPort
}
streamHTTP3Port.Store(port)
if len(tlsConfig.Certificates) > 0 && len(tlsConfig.Certificates[0].Certificate) > 0 {
hash := sha256.Sum256(tlsConfig.Certificates[0].Certificate[0])
streamCertHash.Store(fmt.Sprintf("%x", hash[:]))
}
h3 := &http3.Server{
Addr: addr, Handler: mux, TLSConfig: tlsConfig.Clone(), EnableDatagrams: true,
QUICConfig: &quic.Config{EnableDatagrams: true, EnableStreamResetPartialDelivery: true},
}
server := &wt.Server{H3: h3}
wt.ConfigureHTTP3Server(h3)
endpoint.mu.Lock()
endpoint.server = server
endpoint.mu.Unlock()
go func() {
if err := server.ListenAndServe(); err != nil {
log.Printf("streambus HTTP/3 server: %v", err)
}
}()
}
BroadcastOption
BroadcastOption configures a broadcast call.
type BroadcastOption func(*BroadcastOptions)
BroadcastOptions
BroadcastOptions holds optional parameters for Broadcast.
type BroadcastOptions struct
Fields
| Name | Type | Description |
|---|---|---|
| Session | string |
WithSessionTarget
WithSessionTarget limits a broadcast to a specific session ID.
Parameters
Returns
func WithSessionTarget(sessionID string) BroadcastOption
{
return func(opts *BroadcastOptions) {
opts.Session = sessionID
}
}
connectionWriter
type connectionWriter struct
Methods
func (*connectionWriter) run()
{
writerLoop:
for {
select {
case <-writer.resync:
writer.sendResyncRequired()
return
case <-writer.done:
return
default:
}
select {
case <-writer.resync:
writer.sendResyncRequired()
return
case <-writer.done:
return
case batch := <-writer.queue:
for _, out := range batch {
if !writer.beginWrite() {
continue writerLoop
}
if err := sendOutboundUnlocked(writer.ws, out, writer.writeTimeout); err != nil {
writer.endWrite()
log.Printf("send: %v", err)
writer.stopAndClose()
return
}
writer.endWrite()
}
}
}
}
Returns
func (*connectionWriter) beginWrite() bool
{
writer.mu.Lock()
defer writer.mu.Unlock()
if writer.stopped {
return false
}
writer.writing = true
return true
}
func (*connectionWriter) endWrite()
{
writer.mu.Lock()
writer.writing = false
writer.mu.Unlock()
}
Parameters
Returns
func (*connectionWriter) enqueue(batch func() ([]Outbound, error)) bool
{
writer.mu.Lock()
defer writer.mu.Unlock()
if writer.stopped {
return false
}
messages, err := batch()
if err != nil {
log.Printf("marshal outbound payload: %v", err)
return false
}
select {
case writer.queue <- messages:
return true
default:
writer.stopped = true
writer.resync <- struct{}{}
return false
}
}
func (*connectionWriter) sendResyncRequired()
{
writer.mu.Lock()
writer.writing = true
writer.mu.Unlock()
err := sendOutboundUnlocked(writer.ws, Outbound{
Control: "resync_required",
Error: NewActionError("resync_required", "outbound delivery could not keep up"),
}, writer.writeTimeout)
if err != nil {
log.Printf("send resync_required: %v", err)
}
writer.endWrite()
writer.close(true)
}
func (*connectionWriter) stopAndClose()
{
writer.mu.Lock()
writer.stopped = true
writer.mu.Unlock()
writer.close(false)
}
Parameters
func (*connectionWriter) close(resetDeadline bool)
{
if resetDeadline && writer.writeTimeout > 0 {
_ = writer.ws.SetWriteDeadline(time.Now().Add(writer.writeTimeout))
}
if err := writer.ws.Close(); err != nil {
logger.Debug("close outbound websocket", "error", err)
}
}
func (*connectionWriter) forget()
{
writer.mu.Lock()
writer.stopped = true
writing := writer.writing
close(writer.done)
writer.mu.Unlock()
if writing {
_ = writer.ws.SetWriteDeadline(time.Now())
}
}
Fields
| Name | Type | Description |
|---|---|---|
| ws | *websocket.Conn | |
| writeTimeout | time.Duration | |
| queue | chan []Outbound | |
| resync | chan struct{} | |
| done | chan struct{} | |
| mu | sync.Mutex | |
| stopped | bool | |
| writing | bool |
configureConnectionWriter
Parameters
func configureConnectionWriter(ws *websocket.Conn, writeTimeout time.Duration, queueSize int)
{
if ws == nil || queueSize <= 0 {
return
}
writer := &connectionWriter{
ws: ws,
writeTimeout: writeTimeout,
queue: make(chan []Outbound, queueSize),
resync: make(chan struct{}, 1),
done: make(chan struct{}),
}
if _, loaded := connWriters.LoadOrStore(ws, writer); loaded {
return
}
go writer.run()
}
AnswerControl
AnswerControl replies to an out-of-band control frame and reports whether it
consumed the message.
A browser client cannot send protocol ping frames, so liveness rides on a
control message instead. It carries no sequence, so its answer stays out of
the replay history and out of the message budget, matching the frame level
pong it replaces.
Both this package’s handler and the one in ssc route inbound frames through
here. They are separate loops over the same protocol, and when only one of
them answered a ping an idle connection to an SSC server dropped and
reconnected on every heartbeat.
Parameters
Returns
func AnswerControl(ws *websocket.Conn, msg Inbound) bool
{
if msg.Control != "ping" {
return false
}
SendOutbound(ws, Outbound{Control: "pong"})
return true
}
Uses
wsHandler
Parameters
func wsHandler(ws *websocket.Conn, runtime *WSRuntime)
{
if !runtime.AcquireConnection() {
SendOutbound(ws, Outbound{Error: NewActionError("connection_limit", "connection limit reached")})
if err := ws.Close(); err != nil {
log.Printf("close rejected websocket: %v", err)
}
return
}
defer runtime.ReleaseConnection()
runtime.ConfigureConnection(ws)
var session *Session
var subscribed []string
defer func() {
connMu.Lock()
for _, name := range subscribed {
if set, ok := connections[name]; ok {
delete(set, ws)
if len(set) == 0 {
delete(connections, name)
}
}
}
connMu.Unlock()
SuspendSession(session, runtime.ResumeTTL())
ForgetConnection(ws)
if err := ws.Close(); err != nil {
log.Printf("close websocket: %v", err)
}
}()
for {
var raw []byte
if err := websocket.Message.Receive(ws, &raw); err != nil {
if err == io.EOF {
break
}
log.Printf("recv: %v", err)
return
}
var msg Inbound
if err := json.Unmarshal(raw, &msg); err != nil {
log.Printf("unmarshal: %v", err)
continue
}
if session == nil {
var resumed bool
var err error
session, resumed, err = runtime.OpenSession(ws.Request(), msg.ResumeToken)
if err != nil {
SendOutbound(ws, Outbound{Error: NewActionError("session_rejected", "session rejected")})
return
}
BindSessionConnection(ws, session)
if resumed {
ReplaySession(ws, session, msg.Ack)
} else if msg.ResumeToken != "" {
SendSessionOutbound(ws, session, Outbound{
Control: "resume_rejected",
Error: NewActionError("resume_rejected", "session could not be resumed"),
})
}
}
session.Acknowledge(msg.Ack)
if AnswerControl(ws, msg) {
continue
}
if err := session.AcceptInbound(msg.Sequence); err != nil {
if errors.Is(err, ErrDuplicateMessage) {
continue
}
SendSessionOutbound(ws, session, Outbound{
ID: msg.ID,
Action: msg.Action,
Error: NewActionError("sequence_gap", "client message sequence gap"),
})
continue
}
if !session.AllowMessage(runtime.MessagesPerMinute()) {
SendSessionOutbound(ws, session, Outbound{
ID: msg.ID,
Action: msg.Action,
Error: NewActionError("rate_limited", "message rate limit exceeded"),
})
continue
}
authorizeCtx, cancelAuthorize := runtime.HandlerContext(context.Background())
authorizeErr := runtime.Authorize(authorizeCtx, session, msg)
cancelAuthorize()
if authorizeErr != nil {
SendSessionOutbound(ws, session, Outbound{
Component: msg.Component,
Action: msg.Action,
ID: msg.ID,
Error: NewActionError("forbidden", "message forbidden"),
})
continue
}
if msg.Action != "" {
payload, actionErr := runtime.DispatchAction(context.Background(), session, msg)
SendSessionOutbound(ws, session, Outbound{
Action: msg.Action,
ID: msg.ID,
Payload: payload,
Error: actionErr,
})
continue
}
if msg.Component != "" && msg.Payload != nil && msg.Payload["unsubscribe"] == true {
connMu.Lock()
if set, ok := connections[msg.Component]; ok {
delete(set, ws)
if len(set) == 0 {
delete(connections, msg.Component)
}
}
for index, name := range subscribed {
if name == msg.Component {
subscribed = append(subscribed[:index], subscribed[index+1:]...)
break
}
}
connMu.Unlock()
SendSessionOutbound(ws, session, Outbound{Component: msg.Component, Control: "unsubscribed"})
continue
}
if hc, ok := Get(msg.Component); ok {
connMu.Lock()
if _, ok := connections[msg.Component]; !ok {
connections[msg.Component] = make(map[*websocket.Conn]*Session)
}
if _, tracked := connections[msg.Component][ws]; !tracked {
connections[msg.Component][ws] = session
subscribed = append(subscribed, msg.Component)
}
connMu.Unlock()
resp := hc.HandleWithSession(session, msg.Payload)
if resp != nil {
switch v := resp.(type) {
case *InitSnapshot:
if v != nil {
SendSessionOutbound(ws, session, Outbound{Component: msg.Component, ID: msg.ID, Payload: map[string]any{"initSnapshot": v}})
}
continue
case InitSnapshot:
SendSessionOutbound(ws, session, Outbound{Component: msg.Component, ID: msg.ID, Payload: map[string]any{"initSnapshot": v}})
continue
default:
SendSessionOutbound(ws, session, Outbound{Component: msg.Component, ID: msg.ID, Payload: resp})
continue
}
}
if msg.Payload != nil && msg.Payload["init"] == true {
SendSessionOutbound(ws, session, Outbound{
Component: msg.Component,
Payload: map[string]any{"session": session.ID()},
})
continue
}
}
SendSessionOutbound(ws, session, Outbound{Control: "ack"})
}
}
Broadcast
Broadcast sends the given payload to all connections subscribed to the component name.
Parameters
func Broadcast(name string, payload any, opts ...BroadcastOption)
{
var options BroadcastOptions
for _, opt := range opts {
opt(&options)
}
// Snapshot the (conn, session) pairs under the lock: wsHandler mutates the
// connection map on subscribe/disconnect, so iterating it after releasing
// connMu races with those writes. Sends happen outside the lock.
type target struct {
ws *websocket.Conn
session *Session
}
connMu.RLock()
targets := make([]target, 0, len(connections[name]))
for ws, session := range connections[name] {
targets = append(targets, target{ws: ws, session: session})
}
connMu.RUnlock()
for _, t := range targets {
if options.Session != "" && t.session.ID() != options.Session {
continue
}
SendSessionOutbound(t.ws, t.session, Outbound{Component: name, Payload: payload})
}
streamClientsMu.RLock()
streamTargets := make([]struct {
connection *streamBusConnection
session *Session
}, 0, len(streamClients[name]))
for connection, session := range streamClients[name] {
streamTargets = append(streamTargets, struct {
connection *streamBusConnection
session *Session
}{connection: connection, session: session})
}
streamClientsMu.RUnlock()
for _, target := range streamTargets {
if options.Session != "" && target.session.ID() != options.Session {
continue
}
sendStreamBusSession(target.connection, target.session, Outbound{Component: name, Payload: payload})
}
}
ReplaySession
ReplaySession sends retained messages after the client’s acknowledgement.
Parameters
func ReplaySession(ws *websocket.Conn, session *Session, acknowledged uint64)
{
if session == nil {
return
}
session.outboundMu.Lock()
defer session.outboundMu.Unlock()
accepted, _ := sessionAcceptsConnection(session, ws, true)
if !accepted {
return
}
if writerValue, ok := connWriters.Load(ws); ok {
writerValue.(*connectionWriter).enqueue(func() ([]Outbound, error) {
messages, err := session.ReplayAfter(acknowledged)
if err != nil {
return []Outbound{session.PrepareOutbound(Outbound{
Error: NewActionError("resync_required", "message history is no longer available"),
})}, nil
}
return messages, nil
})
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"),
}), DefaultSSCLimits().WriteTimeout)
return
}
for _, message := range messages {
if err := sendOutboundUnlocked(ws, message, DefaultSSCLimits().WriteTimeout); err != nil {
log.Printf("send replay: %v", err)
_ = ws.Close()
return
}
}
}
SendSessionOutbound
SendSessionOutbound assigns delivery metadata and sends a message. It binds
ws when the session has no active connection. After ResumeSession, callers
must complete the handoff with ReplaySession or BindSessionConnection before
sending.
Parameters
func SendSessionOutbound(ws *websocket.Conn, session *Session, out Outbound)
{
if session == nil {
return
}
session.outboundMu.Lock()
defer session.outboundMu.Unlock()
accepted, handoffPending := sessionAcceptsConnection(session, ws, false)
if !accepted {
if handoffPending {
logger.Debug("session outbound dropped before connection handoff", "session", session.ID())
}
return
}
out, err := prepareOutboundPayload(out)
if err != nil {
log.Printf("marshal outbound payload: %v", err)
return
}
if writerValue, ok := connWriters.Load(ws); ok {
writerValue.(*connectionWriter).enqueue(func() ([]Outbound, error) {
return []Outbound{session.PrepareOutbound(out)}, nil
})
return
}
lock := connectionWriteLock(ws)
lock.Lock()
defer lock.Unlock()
if err := sendOutboundUnlocked(ws, session.PrepareOutbound(out), DefaultSSCLimits().WriteTimeout); err != nil {
log.Printf("send: %v", err)
_ = ws.Close()
}
}
Uses
BindSessionConnection
BindSessionConnection marks ws as the active connection for session delivery.
It binds a session without an active connection and completes the connection
handoff after ResumeSession. It does not replace an active connection.
Parameters
func BindSessionConnection(ws *websocket.Conn, session *Session)
{
if session == nil {
return
}
session.outboundMu.Lock()
accepted, _ := sessionAcceptsConnection(session, ws, true)
if accepted {
session.connectionManaged = true
}
session.outboundMu.Unlock()
}
sessionAcceptsConnection
Parameters
Returns
func sessionAcceptsConnection(session *Session, ws *websocket.Conn, handoff bool) (bool, bool)
{
if ws == nil {
return false, false
}
session.deliveryMu.Lock()
active := session.attached && !session.released
resumePending := session.resumePending
if active && resumePending && handoff {
session.resumePending = false
}
session.deliveryMu.Unlock()
if !active {
return false, false
}
if resumePending && !handoff {
return false, true
}
if session.connection == ws {
return true, false
}
if session.connection != nil || session.streamConnection != nil || (session.connectionManaged && !handoff) {
return false, false
}
session.connection = ws
session.streamConnection = nil
return true, false
}
SendOutbound
SendOutbound queues a write on configured connections and otherwise writes
synchronously with the default deadline.
Parameters
func SendOutbound(ws *websocket.Conn, out Outbound)
{
if writerValue, ok := connWriters.Load(ws); ok {
writerValue.(*connectionWriter).enqueue(func() ([]Outbound, error) {
prepared, err := prepareOutboundPayload(out)
if err != nil {
return nil, err
}
return []Outbound{prepared}, nil
})
return
}
lock := connectionWriteLock(ws)
lock.Lock()
defer lock.Unlock()
var err error
out, err = prepareOutboundPayload(out)
if err != nil {
log.Printf("marshal outbound payload: %v", err)
return
}
if err := sendOutboundUnlocked(ws, out, DefaultSSCLimits().WriteTimeout); err != nil {
log.Printf("send: %v", err)
_ = ws.Close()
}
}
Uses
connectionWriteLock
Parameters
Returns
func connectionWriteLock(ws *websocket.Conn) *sync.Mutex
{
lockValue, _ := connWrites.LoadOrStore(ws, &sync.Mutex{})
return lockValue.(*sync.Mutex)
}
sendOutboundUnlocked
Parameters
Returns
func sendOutboundUnlocked(ws *websocket.Conn, out Outbound, writeTimeout time.Duration) error
{
b, err := marshalOutbound(out)
if err != nil {
return err
}
if writeTimeout > 0 {
if err := ws.SetWriteDeadline(time.Now().Add(writeTimeout)); err != nil {
return err
}
}
if err := websocket.Message.Send(ws, b); err != nil {
return err
}
if writeTimeout > 0 {
return ws.SetWriteDeadline(time.Time{})
}
return nil
}
Uses
prepareOutboundPayload
func prepareOutboundPayload(out Outbound) (Outbound, error)
{
if out.Payload == nil || out.encodedPayload != nil {
return out, nil
}
payload, err := json.Marshal(out.Payload)
if err != nil {
return out, err
}
out.encodedPayload = payload
return out, nil
}
marshalOutbound
Parameters
Returns
func marshalOutbound(out Outbound) ([]byte, error)
{
if out.encodedPayload == nil {
return json.Marshal(out)
}
wire := out
wire.Payload = json.RawMessage(out.encodedPayload)
return json.Marshal(wire)
}
Uses
ForgetConnection
ForgetConnection releases the connection’s outbound delivery resources.
Parameters
func ForgetConnection(ws *websocket.Conn)
{
if writerValue, ok := connWriters.LoadAndDelete(ws); ok {
writerValue.(*connectionWriter).forget()
}
connWrites.Delete(ws)
}
singleConnListener
type singleConnListener struct
Methods
Returns
func (*singleConnListener) Accept() (net.Conn, error)
{
listener.mu.Lock()
if listener.conn != nil {
conn := listener.conn
listener.conn = nil
listener.mu.Unlock()
return conn, nil
}
listener.mu.Unlock()
<-listener.closed
return nil, net.ErrClosed
}
Returns
func (*singleConnListener) Close() error
{
listener.closeOnce.Do(func() { close(listener.closed) })
return nil
}
Fields
| Name | Type | Description |
|---|---|---|
| mu | sync.Mutex | |
| conn | net.Conn | |
| closed | chan struct{} | |
| closeOnce | sync.Once |
openBlockedProtocolSocket
Returns
func openBlockedProtocolSocket(t *testing.T, runtime *WSRuntime) (*websocket.Conn, func())
{
t.Helper()
serverConn, clientConn := net.Pipe()
listener := &singleConnListener{conn: serverConn, closed: make(chan struct{})}
server := &http.Server{
Handler: runtime.Guard(websocket.Handler(func(ws *websocket.Conn) {
wsHandler(ws, runtime)
})),
ReadHeaderTimeout: time.Second,
}
serveDone := make(chan error, 1)
go func() { serveDone <- server.Serve(listener) }()
config, err := websocket.NewConfig("ws://pipe.invalid/ws", "http://pipe.invalid")
if err != nil {
t.Fatalf("configure pipe websocket: %v", err)
}
client, err := websocket.NewClient(config, clientConn)
if err != nil {
_ = server.Close()
_ = listener.Close()
t.Fatalf("open pipe websocket: %v", err)
}
cleanup := func() {
_ = client.SetDeadline(time.Now())
_ = client.Close()
_ = server.Close()
_ = listener.Close()
select {
case err := <-serveDone:
if err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
t.Errorf("serve pipe websocket: %v", err)
}
case <-time.After(time.Second):
t.Error("pipe websocket server did not stop")
}
}
return client, cleanup
}
openRuntimeServer
Returns
func openRuntimeServer(t *testing.T, runtime *WSRuntime) (*httptest.Server, string)
{
t.Helper()
mux := http.NewServeMux()
mux.Handle("/ws", runtime.Guard(websocket.Handler(func(ws *websocket.Conn) {
wsHandler(ws, runtime)
})))
server := httptest.NewServer(mux)
return server, "ws" + strings.TrimPrefix(server.URL, "http") + "/ws"
}
sendTestInbound
Parameters
func sendTestInbound(t *testing.T, ws *websocket.Conn, message Inbound)
{
t.Helper()
data, err := json.Marshal(message)
if err != nil {
t.Fatalf("marshal inbound: %v", err)
}
if err := websocket.Message.Send(ws, data); err != nil {
t.Fatalf("send inbound: %v", err)
}
}
Uses
waitForConnectionCount
Parameters
Returns
func waitForConnectionCount(t *testing.T, component string, count int) map[*websocket.Conn]*Session
{
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
connMu.RLock()
current := make(map[*websocket.Conn]*Session, len(connections[component]))
for ws, session := range connections[component] {
current[ws] = session
}
connMu.RUnlock()
if len(current) == count {
return current
}
time.Sleep(time.Millisecond)
}
t.Fatalf("component %q connection count did not reach %d", component, count)
return nil
}
waitForSessionDetached
func waitForSessionDetached(t *testing.T, session *Session)
{
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
session.deliveryMu.Lock()
attached := session.attached
session.deliveryMu.Unlock()
if !attached {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("session did not detach after its connection closed")
}
TestWriteDeadlineClosesAClientThatDoesNotRead
Parameters
func TestWriteDeadlineClosesAClientThatDoesNotRead(t *testing.T)
{
const componentName = "write-deadline-client"
Register(NewHostComponent(componentName, func(map[string]any) any {
return map[string]any{"ready": true}
}))
runtime := NewWSRuntime(WithSSCLimits(SSCLimits{
WriteTimeout: 25 * time.Millisecond,
OutboundQueueSize: 4,
}))
client, cleanup := openBlockedProtocolSocket(t, runtime)
defer cleanup()
sendTestInbound(t, client, Inbound{Component: componentName, Payload: map[string]any{"init": true}})
targets := waitForConnectionCount(t, componentName, 1)
var session *Session
for _, candidate := range targets {
session = candidate
}
waitForSessionDetached(t, session)
if err := client.SetReadDeadline(time.Now().Add(time.Second)); err == nil {
var raw []byte
if err := websocket.Message.Receive(client, &raw); err == nil {
t.Fatalf("slow client remained open after the write deadline: %s", raw)
}
} else if !errors.Is(err, io.ErrClosedPipe) && !errors.Is(err, net.ErrClosed) {
t.Fatalf("set client read deadline: %v", err)
}
ReleaseSession(session)
}
TestInvalidPayloadDoesNotCloseConnectionOrConsumeSequence
Parameters
func TestInvalidPayloadDoesNotCloseConnectionOrConsumeSequence(t *testing.T)
{
client, server, closeSockets := openWriteTestSocket(t)
defer closeSockets()
configureConnectionWriter(server, time.Second, 4)
session := newSession("invalid-payload")
BindSessionConnection(server, session)
SendSessionOutbound(server, session, Outbound{Payload: make(chan int)})
SendSessionOutbound(server, session, Outbound{Payload: "valid"})
message := receiveOrderedMessage(t, client)
if message.Sequence != 1 || message.Payload != "valid" {
t.Fatalf("valid message after marshal failure = %#v", message)
}
}
TestBroadcastDoesNotWaitForSlowClientAndOverflowResumes
Parameters
func TestBroadcastDoesNotWaitForSlowClientAndOverflowResumes(t *testing.T)
{
const componentName = "bounded-broadcast-client"
Register(NewHostComponent(componentName, func(map[string]any) any {
return map[string]any{"ready": true}
}))
slowRuntime := NewWSRuntime(WithSSCLimits(SSCLimits{
WriteTimeout: 2 * time.Second,
OutboundQueueSize: 2,
ResumeTTL: time.Second,
ReplayMessages: 16,
}))
fastRuntime := NewWSRuntime(WithSSCLimits(SSCLimits{
WriteTimeout: 2 * time.Second,
OutboundQueueSize: 16,
ResumeTTL: time.Second,
ReplayMessages: 16,
}))
slowClient, closeSlow := openBlockedProtocolSocket(t, slowRuntime)
defer closeSlow()
sendTestInbound(t, slowClient, Inbound{Component: componentName, Payload: map[string]any{"init": true}})
slowTargets := waitForConnectionCount(t, componentName, 1)
var (
slowServer *websocket.Conn
slowSession *Session
)
for ws, session := range slowTargets {
slowServer = ws
slowSession = session
}
token := slowSession.ResumeToken()
sessionID := slowSession.ID()
if token == "" {
t.Fatal("slow session was not resumable")
}
deadline := time.Now().Add(time.Second)
for {
slowSession.deliveryMu.Lock()
sequence := slowSession.outboundSeq
slowSession.deliveryMu.Unlock()
writerValue, ok := connWriters.Load(slowServer)
if ok && sequence == 1 && len(writerValue.(*connectionWriter).queue) == 0 {
break
}
if time.Now().After(deadline) {
t.Fatal("slow client writer did not enter its blocked write")
}
time.Sleep(time.Millisecond)
}
server, wsURL := openRuntimeServer(t, fastRuntime)
defer server.Close()
fastClient, err := websocket.Dial(wsURL, "", server.URL)
if err != nil {
t.Fatalf("dial fast websocket: %v", err)
}
defer closeTestResource(t, fastClient)
sendTestInbound(t, fastClient, Inbound{Component: componentName, Payload: map[string]any{"init": true}})
_ = receiveOrderedMessage(t, fastClient)
waitForConnectionCount(t, componentName, 2)
started := time.Now()
for tick := 1; tick <= 3; tick++ {
Broadcast(componentName, map[string]any{"tick": tick})
}
if elapsed := time.Since(started); elapsed > 100*time.Millisecond {
t.Fatalf("broadcast waited for the slow client: %s", elapsed)
}
for tick := 1; tick <= 3; tick++ {
message := receiveOrderedMessage(t, fastClient)
payload, ok := message.Payload.(map[string]any)
if !ok || payload["tick"] != float64(tick) {
t.Fatalf("fast client broadcast %d = %#v", tick, message)
}
}
if err := slowClient.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
t.Fatalf("set slow client read deadline: %v", err)
}
resyncRequired := false
for {
var raw []byte
if err := websocket.Message.Receive(slowClient, &raw); err != nil {
break
}
var message Outbound
if err := json.Unmarshal(raw, &message); err != nil {
t.Fatalf("decode slow client message: %v", err)
}
if message.Control == "resync_required" && message.Error != nil && message.Error.Code == "resync_required" {
resyncRequired = true
}
}
if !resyncRequired {
t.Fatal("queue overflow closed the slow client without resync_required")
}
waitForSessionDetached(t, slowSession)
resumedClient, err := websocket.Dial(wsURL, "", server.URL)
if err != nil {
t.Fatalf("dial resumed websocket: %v", err)
}
sendTestInbound(t, resumedClient, Inbound{Control: "ping", ResumeToken: token})
for sequence := uint64(1); sequence <= 4; sequence++ {
message := receiveOrderedMessage(t, resumedClient)
if message.Session != sessionID || message.Sequence != sequence {
t.Fatalf("resumed message %d = %#v", sequence, message)
}
if sequence >= 2 {
payload, ok := message.Payload.(map[string]any)
if !ok || payload["tick"] != float64(sequence-1) {
t.Fatalf("replayed broadcast %d = %#v", sequence-1, message)
}
}
}
pong := receiveOrderedMessage(t, resumedClient)
if pong.Control != "pong" || pong.Sequence != 0 {
t.Fatalf("resume did not continue on the new connection: %#v", pong)
}
closeTestResource(t, resumedClient)
waitForSessionDetached(t, slowSession)
ReleaseSession(slowSession)
}
TestClientCanUnsubscribeFromBroadcasts
Parameters
func TestClientCanUnsubscribeFromBroadcasts(t *testing.T)
{
const componentName = "broadcast-unsubscribe"
Register(NewHostComponent(componentName, func(map[string]any) any {
return map[string]any{"ready": true}
}))
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("ok"), 0o600); err != nil {
t.Fatalf("write index: %v", err)
}
srv := httptest.NewServer(NewMux(root))
defer srv.Close()
ws, err := websocket.Dial("ws"+strings.TrimPrefix(srv.URL, "http")+"/ws", "", srv.URL)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer closeTestResource(t, ws)
init, _ := json.Marshal(map[string]any{"component": componentName, "payload": map[string]any{"init": true}})
if err := websocket.Message.Send(ws, init); err != nil {
t.Fatalf("subscribe: %v", err)
}
var raw []byte
if err := websocket.Message.Receive(ws, &raw); err != nil {
t.Fatalf("receive init: %v", err)
}
unsubscribe, _ := json.Marshal(map[string]any{"component": componentName, "payload": map[string]any{"unsubscribe": true}})
if err := websocket.Message.Send(ws, unsubscribe); err != nil {
t.Fatalf("unsubscribe: %v", err)
}
if err := websocket.Message.Receive(ws, &raw); err != nil {
t.Fatalf("receive unsubscribe acknowledgement: %v", err)
}
Broadcast(componentName, map[string]any{"unexpected": true})
if err := ws.SetDeadline(time.Now().Add(50 * time.Millisecond)); err != nil {
t.Fatalf("set deadline: %v", err)
}
if err := websocket.Message.Receive(ws, &raw); err == nil {
t.Fatalf("received broadcast after unsubscribe: %s", raw)
}
if err := ws.SetDeadline(time.Time{}); err != nil {
t.Fatalf("reset deadline: %v", err)
}
}
TestBroadcastConcurrentWithConnectionChurn
Broadcast must snapshot the (conn, session) pairs under connMu: clients
subscribing and disconnecting concurrently with broadcasts used to race with
the map iteration. Run with -race to exercise the invariant.
Parameters
func TestBroadcastConcurrentWithConnectionChurn(t *testing.T)
{
const componentName = "broadcast-churn"
Register(NewHostComponentWithSession(componentName, func(_ *Session, _ map[string]any) any {
return map[string]any{"ok": true}
}))
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("ok"), 0o600); err != nil {
t.Fatalf("write index: %v", err)
}
srv := httptest.NewServer(NewMux(root))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws"
init, err := json.Marshal(map[string]any{
"component": componentName,
"payload": map[string]any{"init": true},
})
if err != nil {
t.Fatalf("marshal init: %v", err)
}
var wg sync.WaitGroup
// Churn: connections subscribe, receive the handler response and close.
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 5; j++ {
ws, err := websocket.Dial(wsURL, "", srv.URL)
if err != nil {
continue
}
if err := websocket.Message.Send(ws, init); err == nil {
var raw []byte
_ = websocket.Message.Receive(ws, &raw)
}
closeTestResource(t, ws)
}
}()
}
// Broadcasters run against the same component while the map churns.
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 50; j++ {
Broadcast(componentName, map[string]any{"tick": j})
}
}()
}
wg.Wait()
}
NewMuxFS
NewMuxFS is the fs.FS counterpart of NewMux: it serves the client build from
an fs.FS (for example an embed.FS sub-tree) instead of a directory on disk,
and registers the WebSocket handler at /ws. This lets an application ship as
a single self-contained binary with the build embedded via go:embed, or mount
the rfw endpoints on assets it already holds in memory.
fsys is treated as the complete served tree: index.html, app.wasm and any
static assets must live inside it. Unlike NewMux there is no on-disk sibling
static directory. Options gate the WebSocket endpoint exactly as in NewMux.
Parameters
Returns
func NewMuxFS(fsys fs.FS, opts ...MuxOption) *http.ServeMux
{
runtime := NewWSRuntime(opts...)
mux := http.NewServeMux()
if os.Getenv("RFW_DEVTOOLS") != "" {
mux.Handle("/debug/vars", expvar.Handler())
mux.HandleFunc("/debug/pprof/", pprof.Index)
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
}
fileServer := http.FileServerFS(fsys)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if regularFileFS(fsys, r.URL.Path) {
setWasmEncodingHeaders(w, r.URL.Path, r.URL.Query().Get("v") != "")
fileServer.ServeHTTP(w, r)
return
}
// Serve index.html only for HTML requests or bare paths, so a missing
// asset (CSS, JS, image) still returns 404 instead of HTML.
accept := r.Header.Get("Accept")
if strings.Contains(accept, "text/html") || r.URL.Path == "/" || r.URL.Path == "" {
serveIndexFS(w, r, fsys)
return
}
http.NotFound(w, r)
})
mux.Handle("/ws", runtime.Guard(websocket.Handler(func(ws *websocket.Conn) {
wsHandler(ws, runtime)
})))
return mux
}
serveIndexFS
serveIndexFS writes fsys’s index.html, the SPA entry point.
Parameters
func serveIndexFS(w http.ResponseWriter, r *http.Request, fsys fs.FS)
{
http.ServeFileFS(w, r, fsys, "index.html")
}
regularFileFS
regularFileFS reports whether name (a URL path) maps to a regular file in
fsys, the fs.FS analogue of regularFile.
Parameters
Returns
func regularFileFS(fsys fs.FS, name string) bool
{
name = strings.TrimPrefix(name, "/")
if name == "" || !fs.ValidPath(name) {
return false
}
info, err := fs.Stat(fsys, name)
return err == nil && !info.IsDir()
}
Session
Session represents per-connection state for a WebSocket client.
It exposes an isolated StoreManager and a context bag for arbitrary data.
type Session struct
Methods
ResumeToken returns the opaque token used to resume this session.
Returns
func (*Session) ResumeToken() string
{ return s.resumeToken }
StoreManager returns the session-local store registry.
Returns
func (*Session) StoreManager() *state.StoreManager
{ return s.stores }
ContextGet retrieves a value from the session context.
Parameters
Returns
func (*Session) ContextGet(key string) (any, bool)
{
s.ctxMu.RLock()
defer s.ctxMu.RUnlock()
v, ok := s.ctx[key]
return v, ok
}
ContextSet stores a value in the session context.
Parameters
func (*Session) ContextSet(key string, value any)
{
s.ctxMu.Lock()
s.ctx[key] = value
s.ctxMu.Unlock()
}
ContextDelete removes a value from the session context.
Parameters
func (*Session) ContextDelete(key string)
{
s.ctxMu.Lock()
delete(s.ctx, key)
s.ctxMu.Unlock()
}
Snapshot returns a copy of all stores registered in this session.
Returns
func (*Session) Snapshot() map[string]map[string]map[string]any
{
return s.stores.Snapshot()
}
resumableLocked reports whether a detached session can still be reattached.
Returns
func (*Session) resumableLocked() bool
{
return !s.released && !s.attached &&
(s.expires.IsZero() || !time.Now().After(s.expires))
}
markResumedLocked attaches the session and cancels its retention timer.
func (*Session) markResumedLocked()
{
s.attached = true
s.resumePending = true
s.expires = time.Time{}
if s.expiryTimer != nil {
s.expiryTimer.Stop()
s.expiryTimer = nil
}
}
AcceptInbound validates and records an inbound sequence.
Parameters
Returns
func (*Session) AcceptInbound(sequence uint64) error
{
if s == nil || sequence == 0 {
return nil
}
s.deliveryMu.Lock()
defer s.deliveryMu.Unlock()
if sequence <= s.inboundSeq {
return ErrDuplicateMessage
}
if s.inboundSeq != 0 && sequence != s.inboundSeq+1 {
return ErrSequenceGap
}
s.inboundSeq = sequence
return nil
}
AllowMessage enforces a fixed per-session message window.
Parameters
Returns
func (*Session) AllowMessage(limit int) bool
{
if s == nil || limit <= 0 {
return true
}
s.deliveryMu.Lock()
defer s.deliveryMu.Unlock()
now := time.Now()
if s.rateStart.IsZero() || now.Sub(s.rateStart) >= time.Minute {
s.rateStart = now
s.rateCount = 0
}
s.rateCount++
return s.rateCount <= limit
}
PrepareOutbound assigns delivery metadata and stores replay history.
func (*Session) PrepareOutbound(out Outbound) Outbound
{
if s == nil {
return out
}
s.deliveryMu.Lock()
defer s.deliveryMu.Unlock()
s.outboundSeq++
out.Session = s.id
out.Sequence = s.outboundSeq
out.Ack = s.inboundSeq
out.ResumeToken = s.resumeToken
if s.replayLimit > 0 {
s.replay = append(s.replay, out)
if extra := len(s.replay) - s.replayLimit; extra > 0 {
copy(s.replay, s.replay[extra:])
s.replay = s.replay[:s.replayLimit]
}
}
return out
}
Acknowledge removes outbound messages confirmed by the client.
Parameters
func (*Session) Acknowledge(sequence uint64)
{
if s == nil || sequence == 0 {
return
}
s.deliveryMu.Lock()
defer s.deliveryMu.Unlock()
remove := 0
for remove < len(s.replay) && s.replay[remove].Sequence <= sequence {
remove++
}
if remove > 0 {
s.replay = append([]Outbound(nil), s.replay[remove:]...)
}
}
ReplayAfter returns retained outbound messages after sequence.
Parameters
Returns
func (*Session) ReplayAfter(sequence uint64) ([]Outbound, error)
{
if s == nil {
return nil, nil
}
s.deliveryMu.Lock()
defer s.deliveryMu.Unlock()
if len(s.replay) == 0 {
return nil, nil
}
if sequence+1 < s.replay[0].Sequence {
return nil, ErrReplayUnavailable
}
index := 0
for index < len(s.replay) && s.replay[index].Sequence <= sequence {
index++
}
return append([]Outbound(nil), s.replay[index:]...), nil
}
Fields
| Name | Type | Description |
|---|---|---|
| id | string | |
| resumeToken | string | |
| stores | *state.StoreManager | |
| ctxMu | sync.RWMutex | |
| ctx | map[string]any | |
| deliveryMu | sync.Mutex | |
| outboundMu | sync.Mutex | |
| connection | *websocket.Conn | |
| streamConnection | *streamBusConnection | |
| connectionManaged | bool | |
| resumePending | bool | |
| attached | bool | |
| released | bool | |
| expires | time.Time | |
| expiryTimer | *time.Timer | |
| inboundSeq | uint64 | |
| outboundSeq | uint64 | |
| rateStart | time.Time | |
| rateCount | int | |
| replayLimit | int | |
| replay | []Outbound |
sessionOptions
type sessionOptions struct
Fields
| Name | Type | Description |
|---|---|---|
| resumeToken | string | |
| replayLimit | int |
newSession
Parameters
Returns
func newSession(id string, options ...sessionOptions) *Session
{
var config sessionOptions
if len(options) > 0 {
config = options[0]
}
return &Session{
id: id,
resumeToken: config.resumeToken,
stores: state.NewStoreManager(),
ctx: make(map[string]any),
attached: true,
replayLimit: config.replayLimit,
}
}
AllocateSession
AllocateSession creates and registers a session.
Returns
func AllocateSession() *Session
{
session, _ := allocateSession(0, 0)
return session
}
AllocateResumableSession
AllocateResumableSession creates a session with ordered delivery history.
Parameters
Returns
func AllocateResumableSession(replayLimit int) *Session
{
session, _ := allocateSession(replayLimit, 0)
return session
}
allocateSession
Parameters
Returns
func allocateSession(replayLimit, maxSessions int) (*Session, error)
{
id := generateSessionID()
token := ""
if replayLimit > 0 {
token = generateSessionID() + generateSessionID()
}
session := newSession(id, sessionOptions{resumeToken: token, replayLimit: replayLimit})
sessionMu.Lock()
if maxSessions > 0 && len(sessions) >= maxSessions {
sessionMu.Unlock()
return nil, ErrSessionLimit
}
sessions[id] = session
if token != "" {
sessionByToken[token] = session
}
sessionMu.Unlock()
return session, nil
}
SuspendSession
SuspendSession detaches a connection and retains resumable state for ttl.
Parameters
func SuspendSession(session *Session, ttl time.Duration)
{
if session == nil {
return
}
session.outboundMu.Lock()
session.deliveryMu.Lock()
if !session.attached {
session.deliveryMu.Unlock()
session.outboundMu.Unlock()
return
}
session.connection = nil
session.streamConnection = nil
session.attached = false
if ttl <= 0 || session.resumeToken == "" {
session.deliveryMu.Unlock()
session.outboundMu.Unlock()
ReleaseSession(session)
return
}
expires := time.Now().Add(ttl)
session.expires = expires
session.expiryTimer = time.AfterFunc(ttl, func() {
releaseSession(session, expires)
})
session.deliveryMu.Unlock()
session.outboundMu.Unlock()
}
ResumeSession
ResumeSession attaches a disconnected session by opaque token.
The new socket must call ReplaySession or BindSessionConnection before sends.
Parameters
Returns
func ResumeSession(token string) (*Session, bool)
{
session := sessionForToken(token)
if session == nil {
return nil, false
}
session.deliveryMu.Lock()
defer session.deliveryMu.Unlock()
if !session.resumableLocked() {
return nil, false
}
session.markResumedLocked()
return session, true
}
resumeCandidate
resumeCandidate reports the session a token can currently resume without
mutating it, so an authorization decision can be taken before the session is
attached, its expiry cleared or its connection replaced.
Parameters
Returns
func resumeCandidate(token string) (*Session, bool)
{
session := sessionForToken(token)
if session == nil {
return nil, false
}
session.deliveryMu.Lock()
defer session.deliveryMu.Unlock()
if !session.resumableLocked() {
return nil, false
}
return session, true
}
commitResume
commitResume attaches a candidate that authorization approved. It fails
closed when the token stopped mapping to that exact session or the session is
no longer resumable, so a concurrent attempt cannot reattach a session other
than the one that was authorized.
Parameters
Returns
func commitResume(token string, candidate *Session) bool
{
if candidate == nil || sessionForToken(token) != candidate {
return false
}
candidate.deliveryMu.Lock()
defer candidate.deliveryMu.Unlock()
if !candidate.resumableLocked() {
return false
}
candidate.markResumedLocked()
return true
}
sessionForToken
Parameters
Returns
func sessionForToken(token string) *Session
{
if token == "" {
return nil
}
sessionMu.RLock()
defer sessionMu.RUnlock()
return sessionByToken[token]
}
ReleaseSession
ReleaseSession removes a session from the registry.
Parameters
func ReleaseSession(session *Session)
{
releaseSession(session, time.Time{})
}
releaseSession
func releaseSession(session *Session, expectedExpiry time.Time)
{
if session == nil {
return
}
session.outboundMu.Lock()
session.deliveryMu.Lock()
if session.released || (!expectedExpiry.IsZero() &&
(session.attached || !session.expires.Equal(expectedExpiry))) {
session.deliveryMu.Unlock()
session.outboundMu.Unlock()
return
}
session.released = true
if session.expiryTimer != nil {
session.expiryTimer.Stop()
session.expiryTimer = nil
}
session.connection = nil
session.streamConnection = nil
session.connectionManaged = false
session.resumePending = false
session.attached = false
session.deliveryMu.Unlock()
session.outboundMu.Unlock()
sessionMu.Lock()
if sessions[session.id] == session {
delete(sessions, session.id)
}
if session.resumeToken != "" && sessionByToken[session.resumeToken] == session {
delete(sessionByToken, session.resumeToken)
}
sessionMu.Unlock()
}
SessionByID
SessionByID retrieves a session for the given ID.
Parameters
Returns
func SessionByID(id string) (*Session, bool)
{
sessionMu.RLock()
defer sessionMu.RUnlock()
s, ok := sessions[id]
return s, ok
}
generateSessionID
Returns
func generateSessionID() string
{
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
panic(err)
}
return hex.EncodeToString(buf)
}
TestSessionIsolation
Parameters
func TestSessionIsolation(t *testing.T)
{
t.Helper()
registry = make(map[string]*ServerComponent)
const componentName = "SessionHost"
Register(NewHostComponentWithSession(componentName, func(session *Session, payload map[string]any) any {
const storeKey = "counter"
storeVal, ok := session.ContextGet(storeKey)
var store *state.Store
if ok {
store = storeVal.(*state.Store)
} else {
store = session.StoreManager().NewStore("counter")
store.Set("value", 0)
session.ContextSet(storeKey, store)
}
if inc, ok := payload["increment"].(bool); ok && inc {
current, _ := store.Get("value").(int)
store.Set("value", current+1)
}
return map[string]any{"value": store.Get("value")}
}))
root := t.TempDir()
// Ensure an index exists so NewMux can serve fallback responses without error.
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("ok"), 0o600); err != nil {
t.Fatalf("write index: %v", err)
}
srv := httptest.NewServer(loggingMiddleware(NewMux(root)))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws"
type sessionConn struct {
ws *websocket.Conn
id string
idx int
}
dial := func(idx int) sessionConn {
ws, err := websocket.Dial(wsURL, "", srv.URL)
if err != nil {
t.Fatalf("dial %d: %v", idx, err)
}
init := map[string]any{
"component": componentName,
"payload": map[string]any{"init": true},
}
raw, err := json.Marshal(init)
if err != nil {
t.Fatalf("marshal init %d: %v", idx, err)
}
if err := websocket.Message.Send(ws, raw); err != nil {
t.Fatalf("send init %d: %v", idx, err)
}
var respRaw []byte
if err := websocket.Message.Receive(ws, &respRaw); err != nil {
t.Fatalf("recv init %d: %v", idx, err)
}
var resp struct {
Component string `json:"component"`
Payload map[string]any `json:"payload"`
Session string `json:"session"`
}
if err := json.Unmarshal(respRaw, &resp); err != nil {
t.Fatalf("unmarshal init %d: %v", idx, err)
}
if resp.Session == "" {
t.Fatalf("session id missing for conn %d", idx)
}
if resp.Component != componentName {
t.Fatalf("unexpected component %s", resp.Component)
}
if val, ok := resp.Payload["value"].(float64); !ok || val != 0 {
t.Fatalf("unexpected init value for conn %d: %v", idx, resp.Payload)
}
return sessionConn{ws: ws, id: resp.Session, idx: idx}
}
sessions := []sessionConn{dial(0), dial(1)}
defer func() {
for _, sc := range sessions {
closeTestResource(t, sc.ws)
}
}()
counts := []int{5, 2}
if len(counts) != len(sessions) {
t.Fatalf("mismatched counts")
}
errCh := make(chan error, len(sessions))
var wg sync.WaitGroup
for i, sc := range sessions {
wg.Add(1)
count := counts[i]
go func(sc sessionConn, target int) {
defer wg.Done()
for j := 0; j < target; j++ {
payload := map[string]any{
"component": componentName,
"payload": map[string]any{"increment": true},
}
raw, err := json.Marshal(payload)
if err != nil {
errCh <- fmt.Errorf("marshal increment idx=%d: %w", sc.idx, err)
return
}
if err := websocket.Message.Send(sc.ws, raw); err != nil {
errCh <- fmt.Errorf("send increment idx=%d: %w", sc.idx, err)
return
}
var respRaw []byte
if err := websocket.Message.Receive(sc.ws, &respRaw); err != nil {
errCh <- fmt.Errorf("recv increment idx=%d: %w", sc.idx, err)
return
}
var resp struct {
Component string `json:"component"`
Payload map[string]any `json:"payload"`
Session string `json:"session"`
}
if err := json.Unmarshal(respRaw, &resp); err != nil {
errCh <- fmt.Errorf("unmarshal increment idx=%d: %w", sc.idx, err)
return
}
if resp.Session != sc.id {
errCh <- fmt.Errorf("response session mismatch idx=%d: got %s want %s", sc.idx, resp.Session, sc.id)
return
}
}
errCh <- nil
}(sc, count)
}
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
t.Fatal(err)
}
}
for i, sc := range sessions {
sess, ok := SessionByID(sc.id)
if !ok {
t.Fatalf("session %d not found", i)
}
snap := sess.Snapshot()
module := snap["default"]
if module == nil {
t.Fatalf("session %d missing default module snapshot", i)
}
counter := module["counter"]
if counter == nil {
t.Fatalf("session %d missing counter store", i)
}
val, ok := counter["value"].(int)
if !ok {
t.Fatalf("session %d missing value entry: %v", i, counter)
}
if val != counts[i] {
t.Fatalf("session %d got value %d want %d", i, val, counts[i])
}
}
if sessions[0].id == sessions[1].id {
t.Fatal("session ids should differ")
}
}
TestStreamBusWebTransportProtocol
Parameters
func TestStreamBusWebTransportProtocol(t *testing.T)
{
t.Setenv("RFW_TRANSPORT", "streambus")
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("ok"), 0o600); err != nil {
t.Fatal(err)
}
mux := NewMux(root)
value, ok := streamEndpoints.Load(mux)
if !ok {
t.Fatal("StreamBus endpoint was not registered")
}
endpoint := value.(*streamBusEndpoint)
certificate, err := generateSelfSignedCert()
if err != nil {
t.Fatal(err)
}
leaf, err := x509.ParseCertificate(certificate.Certificate[0])
if err != nil {
t.Fatal(err)
}
roots := x509.NewCertPool()
roots.AddCert(leaf)
serverTLS := &tls.Config{Certificates: []tls.Certificate{certificate}, NextProtos: []string{http3.NextProtoH3}}
h3 := &http3.Server{
TLSConfig: serverTLS, Handler: mux, EnableDatagrams: true,
QUICConfig: &quic.Config{EnableDatagrams: true, EnableStreamResetPartialDelivery: true},
}
server := &wt.Server{H3: h3}
wt.ConfigureHTTP3Server(h3)
endpoint.mu.Lock()
endpoint.server = server
endpoint.mu.Unlock()
address, err := net.ResolveUDPAddr("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
packet, err := net.ListenUDP("udp", address)
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- server.Serve(packet) }()
t.Cleanup(func() {
_ = server.Close()
_ = packet.Close()
select {
case <-done:
case <-time.After(time.Second):
t.Error("WebTransport server did not stop")
}
})
dialer := &wt.Dialer{
TLSClientConfig: &tls.Config{RootCAs: roots, ServerName: "localhost"},
QUICConfig: &quic.Config{EnableDatagrams: true, EnableStreamResetPartialDelivery: true},
}
t.Cleanup(func() { _ = dialer.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
url := fmt.Sprintf("https://localhost:%d%s", packet.LocalAddr().(*net.UDPAddr).Port, streamBusPath)
response, session, err := dialer.Dial(ctx, url, nil)
if err != nil {
t.Fatal(err)
}
defer func() { _ = response.Body.Close() }()
if response.StatusCode != http.StatusOK {
t.Fatalf("status = %d", response.StatusCode)
}
t.Cleanup(func() { _ = session.CloseWithError(0, "") })
stream, err := session.OpenStreamSync(ctx)
if err != nil {
t.Fatal(err)
}
inbound, err := json.Marshal(Inbound{Component: "missing", Sequence: 1})
if err != nil {
t.Fatal(err)
}
if err := writeFrame(stream, inbound); err != nil {
t.Fatal(err)
}
payload, err := readFrame(bufio.NewReader(stream), 1<<20)
if err != nil {
t.Fatal(err)
}
var outbound Outbound
if err := json.Unmarshal(payload, &outbound); err != nil {
t.Fatal(err)
}
if outbound.Control != "ack" || outbound.Session == "" || outbound.Sequence == 0 {
t.Fatalf("unexpected response: %#v", outbound)
}
}
statusRecorder
type statusRecorder struct
Methods
Parameters
func (*statusRecorder) WriteHeader(code int)
{
r.status = code
r.ResponseWriter.WriteHeader(code)
}
Returns
func (*statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error)
{
if h, ok := r.ResponseWriter.(http.Hijacker); ok {
return h.Hijack()
}
return nil, nil, errors.New("http.Hijacker not supported")
}
Fields
| Name | Type | Description |
|---|---|---|
| status | int |
loggingMiddleware
Parameters
Returns
func loggingMiddleware(next http.Handler) http.Handler
{
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
start := time.Now()
next.ServeHTTP(rec, r)
logger.Info("request", "method", r.Method, "path", r.URL.Path, "status", rec.status, "duration", time.Since(start))
})
}
TestNewMuxDebugEndpoints
Parameters
func TestNewMuxDebugEndpoints(t *testing.T)
{
t.Setenv("RFW_DEVTOOLS", "1")
mux := NewMux(t.TempDir())
ts := httptest.NewServer(mux)
defer ts.Close()
resp, err := http.Get(ts.URL + "/debug/vars")
if err != nil {
t.Fatalf("vars request failed: %v", err)
}
if err := resp.Body.Close(); err != nil {
t.Fatalf("close debug response: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
resp, err = http.Get(ts.URL + "/debug/pprof/")
if err != nil {
t.Fatalf("pprof request failed: %v", err)
}
if err := resp.Body.Close(); err != nil {
t.Fatalf("close pprof response: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
TestHostComponent
TestHostComponent verifies registration and handler execution.
Parameters
func TestHostComponent(t *testing.T)
{
called := false
hc := NewHostComponent("cmp", func(payload map[string]any) any {
called = true
if payload["x"] != 1 {
t.Fatalf("unexpected payload: %v", payload)
}
return "ok"
})
Register(hc)
got, ok := Get("cmp")
if !ok || got != hc {
t.Fatalf("component not registered")
}
if resp := hc.Handle(map[string]any{"x": 1}); resp != "ok" || !called {
t.Fatalf("handler not executed or wrong response: %v", resp)
}
}
TestHostComponentWithSession
Parameters
func TestHostComponentWithSession(t *testing.T)
{
hc := NewHostComponentWithSession("withSession", func(session *Session, payload map[string]any) any {
if session == nil {
t.Fatalf("session should not be nil")
}
store := session.StoreManager().NewStore("test")
store.Set("value", payload["v"])
return store.Snapshot()
})
sess := newSession("abc")
resp := hc.HandleWithSession(sess, map[string]any{"v": 42})
snap, ok := resp.(map[string]any)
if !ok {
t.Fatalf("unexpected response type %T", resp)
}
if snap["value"] != 42 {
t.Fatalf("unexpected store snapshot: %v", snap)
}
if !hc.SessionAware() {
t.Fatalf("expected session aware component")
}
if hc.StoreManager(sess) != sess.StoreManager() {
t.Fatalf("StoreManager helper mismatch")
}
}
TestLogLevel
TestLogLevel checks environment variable parsing.
Parameters
func TestLogLevel(t *testing.T)
{
t.Setenv("RFW_LOG_LEVEL", "debug")
if lvl := logLevel(); lvl.String() != "DEBUG" {
t.Fatalf("expected DEBUG level, got %s", lvl)
}
t.Setenv("RFW_LOG_LEVEL", "warn")
if lvl := logLevel(); lvl.String() != "WARN" {
t.Fatalf("expected WARN level, got %s", lvl)
}
t.Setenv("RFW_LOG_LEVEL", "")
if lvl := logLevel(); lvl.String() != "INFO" {
t.Fatalf("expected INFO level, got %s", lvl)
}
}
TestGenerateSelfSignedCert
TestGenerateSelfSignedCert ensures a certificate is generated.
Parameters
func TestGenerateSelfSignedCert(t *testing.T)
{
cert, err := generateSelfSignedCert()
if err != nil {
t.Fatalf("generateSelfSignedCert returned error: %v", err)
}
if len(cert.Certificate) == 0 {
t.Fatalf("expected certificate data")
}
}
ResolveRoot
ResolveRoot resolves a content root relative to the executable when needed.
Parameters
Returns
func ResolveRoot(root string) string
{
if _, err := os.Stat(root); err == nil {
return root
}
if exe, err := os.Executable(); err == nil {
candidate := filepath.Join(filepath.Dir(exe), "..", root)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
return root
}
NewMux
NewMux returns an HTTP mux that serves static files from root and the
WebSocket handler at /ws. Options gate the WebSocket endpoint; by default it
accepts any origin and identity.
Parameters
Returns
func NewMux(root string, opts ...MuxOption) *http.ServeMux
{
root = ResolveRoot(root)
runtime := NewWSRuntime(opts...)
staticRoot := filepath.Join(root, "..", "static")
mux := http.NewServeMux()
if os.Getenv("RFW_DEVTOOLS") != "" {
mux.Handle("/debug/vars", expvar.Handler())
mux.HandleFunc("/debug/pprof/", pprof.Index)
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
}
fs := http.FileServer(http.Dir(root))
rootDir := http.Dir(root)
var sfs http.Handler
var staticDir http.Dir
if _, err := os.Stat(staticRoot); err == nil {
staticDir = http.Dir(staticRoot)
sfs = http.FileServer(staticDir)
}
if sfs != nil {
mux.Handle("/static/", http.StripPrefix("/static", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
setWasmEncodingHeaders(w, r.URL.Path, r.URL.Query().Get("v") != "")
sfs.ServeHTTP(w, r)
})))
}
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// A compressed artifact answers the raw URL whenever the client says
// it can decode one. The raw bundle only reaches a client that asked
// for no encoding, or a build that produced no artifact.
if negotiateWasm(w, r, rootDir, r.URL.Path) {
return
}
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")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
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)
})))
registerStreamBus(mux, runtime)
return mux
}
ListenAndServe
ListenAndServe starts an HTTP server using NewMux to serve files and the
WebSocket endpoint.
Parameters
Returns
func ListenAndServe(addr, root string) error
{
logger.Info("serving HTTP", "addr", addr)
return newHTTPServer(addr, loggingMiddleware(NewMux(root))).ListenAndServe()
}
ListenAndServeWithMux
ListenAndServeWithMux starts an HTTP server using the provided mux.
Parameters
Returns
func ListenAndServeWithMux(addr string, mux *http.ServeMux) error
{
logger.Info("serving HTTP", "addr", addr)
return newHTTPServer(addr, loggingMiddleware(mux)).ListenAndServe()
}
ListenAndServeTLS
ListenAndServeTLS starts an HTTPS server using a self-signed certificate
and NewMux to serve files and the WebSocket endpoint.
Parameters
Returns
func ListenAndServeTLS(addr, root string) error
{
cert, err := generateSelfSignedCert()
if err != nil {
return err
}
mux := NewMux(root)
tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert}}
startStreamBusHTTP3(addr, mux, tlsConfig)
srv := newHTTPServer(addr, loggingMiddleware(mux))
srv.TLSConfig = tlsConfig
logger.Info("serving HTTPS", "addr", addr)
return srv.ListenAndServeTLS("", "")
}
ListenAndServeTLSWithMux
ListenAndServeTLSWithMux starts an HTTPS server using a self-signed certificate
and the provided mux, preserving any additional routes registered by callers.
Parameters
Returns
func ListenAndServeTLSWithMux(addr string, mux *http.ServeMux) error
{
cert, err := generateSelfSignedCert()
if err != nil {
return err
}
tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert}}
startStreamBusHTTP3(addr, mux, tlsConfig)
srv := newHTTPServer(addr, loggingMiddleware(mux))
srv.TLSConfig = tlsConfig
logger.Info("serving HTTPS", "addr", addr)
return srv.ListenAndServeTLS("", "")
}
newHTTPServer
Parameters
Returns
func newHTTPServer(addr string, handler http.Handler) *http.Server
{
return &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
}
}
regularFile
Parameters
Returns
func regularFile(root http.Dir, name string) bool
{
f, err := root.Open(name)
if err != nil {
return false
}
info, statErr := f.Stat()
closeErr := f.Close()
return statErr == nil && closeErr == nil && !info.IsDir()
}
devMode
devMode reports whether the server is running under rfw dev. The dev command
exports RFW_DEV_BUILD=1 and propagates it to the SSC host child via os.Environ,
so both the static and host-proxied serving paths observe it.
Returns
func devMode() bool
{ return os.Getenv("RFW_DEV_BUILD") == "1" }
setWasmEncodingHeaders
Parameters
func setWasmEncodingHeaders(w http.ResponseWriter, path string, versioned bool)
{
// In dev, nothing may be cached: the wasm version pointer lives in
// rfw_config.js and the binary is fetched as app.wasm?v=<hash>. Caching
// either one leaves the browser re-requesting a stale ?v= against an
// immutable entry, so rebuilds are never picked up. no-store on every asset
// forces a fresh fetch each load. Production keeps the immutable policy.
if devMode() {
w.Header().Set("Cache-Control", "no-store")
} else if revalidates(path) {
w.Header().Set("Cache-Control", "no-cache")
}
if !wasmRequest(path) {
return
}
header := w.Header()
setWasmCacheControl(header, versioned)
encoding, compressed := artifactEncoding(path)
if !compressed {
return
}
// A directly addressed artifact still has to be labelled, or the browser
// hands compressed bytes to WebAssembly instead of decoding them.
header.Set("Content-Encoding", encoding)
header.Set("Content-Type", "application/wasm")
addVaryAcceptEncoding(header)
}
generateSelfSignedCert
Returns
func generateSelfSignedCert() (tls.Certificate, error)
{
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return tls.Certificate{}, err
}
tmpl := x509.Certificate{
SerialNumber: big.NewInt(1),
NotBefore: time.Now(),
// WebTransport accepts certificate hashes for development certificates
// only when their validity is at most two weeks.
NotAfter: time.Now().Add(13 * 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)
}
testClientFS
Returns
func testClientFS() fstest.MapFS
{
return fstest.MapFS{
"index.html": {Data: []byte("<!doctype html><div id=app></div>")},
"app.wasm": {Data: []byte("\x00asm")},
"app.wasm.br": {Data: []byte("brotli-bytes")},
"rfw_config.js": {Data: []byte("//cfg")},
"assets/app.css": {Data: []byte(".a{}")},
}
}
TestNewMuxFSServesEmbeddedBuild
Parameters
func TestNewMuxFSServesEmbeddedBuild(t *testing.T)
{
srv := httptest.NewServer(NewMuxFS(testClientFS()))
defer srv.Close()
type result struct {
status int
header http.Header
}
// get issues the request and closes the body before returning, so the
// assertions never hold an open response.
get := func(path, accept string) result {
req, err := http.NewRequest(http.MethodGet, srv.URL+path, nil)
if err != nil {
t.Fatalf("new request %s: %v", path, err)
}
if accept != "" {
req.Header.Set("Accept", accept)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("get %s: %v", path, err)
}
if cerr := resp.Body.Close(); cerr != nil {
t.Fatalf("close body %s: %v", path, cerr)
}
return result{status: resp.StatusCode, header: resp.Header}
}
if got := get("/", "text/html").status; got != http.StatusOK {
t.Fatalf("root status = %d, want 200", got)
}
if got := get("/assets/app.css", "").status; got != http.StatusOK {
t.Fatalf("nested asset status = %d, want 200", got)
}
if got := get("/app.wasm?v=abc", "").header.Get("Cache-Control"); got != "public, max-age=31536000, immutable" {
t.Fatalf("versioned wasm Cache-Control = %q", got)
}
if got := get("/app.wasm.br", "").header.Get("Content-Encoding"); got != "br" {
t.Fatalf("wasm.br Content-Encoding = %q, want br", got)
}
if got := get("/rfw_config.js", "").header.Get("Cache-Control"); got != "no-cache" {
t.Fatalf("rfw_config.js Cache-Control = %q, want no-cache", got)
}
// An unknown HTML route falls back to index.html (single-page app).
if got := get("/dashboard/live", "text/html").status; got != http.StatusOK {
t.Fatalf("unknown html route status = %d, want 200 (index fallback)", got)
}
// A missing non-HTML asset is a 404, not the index.
if got := get("/missing.css", "").status; got != http.StatusNotFound {
t.Fatalf("missing asset status = %d, want 404", got)
}
// A plain GET is not a WebSocket handshake, so /ws rejects it, but it must be
// routed rather than falling through to the catch-all 404.
if got := get("/ws", "").status; got == http.StatusNotFound {
t.Fatalf("/ws returned 404, endpoint not registered")
}
}
wsProbe
Parameters
Returns
func wsProbe(t *testing.T, mux *http.ServeMux, origin string) int
{
t.Helper()
srv := httptest.NewServer(mux)
defer srv.Close()
req, err := http.NewRequest(http.MethodGet, srv.URL+"/ws", nil)
if err != nil {
t.Fatalf("request: %v", err)
}
if origin != "" {
req.Header.Set("Origin", origin)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
if err := resp.Body.Close(); err != nil {
t.Fatalf("close response: %v", err)
}
return resp.StatusCode
}
TestWSOpenByDefault
Without options the endpoint stays open: a plain GET reaches the WebSocket
handler (which rejects the missing upgrade with 400, not a guard status).
Parameters
func TestWSOpenByDefault(t *testing.T)
{
mux := NewMux(t.TempDir())
if code := wsProbe(t, mux, "http://evil.example"); code == http.StatusForbidden || code == http.StatusUnauthorized {
t.Fatalf("default mux rejected connection: %d", code)
}
}
TestWSOriginAllowlist
Parameters
func TestWSOriginAllowlist(t *testing.T)
{
mux := NewMux(t.TempDir(), WithOriginAllowlist("https://app.example.com"))
if code := wsProbe(t, mux, "http://evil.example"); code != http.StatusForbidden {
t.Fatalf("expected 403 for unlisted origin, got %d", code)
}
if code := wsProbe(t, mux, ""); code != http.StatusForbidden {
t.Fatalf("expected 403 for missing origin, got %d", code)
}
if code := wsProbe(t, mux, "https://app.example.com"); code == http.StatusForbidden {
t.Fatalf("allowed origin rejected: %d", code)
}
}
TestWSAuthFunc
Parameters
func TestWSAuthFunc(t *testing.T)
{
mux := NewMux(t.TempDir(), WithAuthFunc(func(r *http.Request) bool {
return r.Header.Get("Origin") == "https://trusted.example.com"
}))
if code := wsProbe(t, mux, "http://evil.example"); code != http.StatusUnauthorized {
t.Fatalf("expected 401 for rejected auth, got %d", code)
}
if code := wsProbe(t, mux, "https://trusted.example.com"); code == http.StatusUnauthorized {
t.Fatalf("accepted auth rejected: %d", code)
}
}
TestWSConnectionLimit
Parameters
func TestWSConnectionLimit(t *testing.T)
{
runtime := NewWSRuntime(WithSSCLimits(SSCLimits{MaxConnections: 1}))
if !runtime.AcquireConnection() {
t.Fatal("first connection was rejected")
}
if runtime.AcquireConnection() {
t.Fatal("second connection exceeded the limit")
}
runtime.ReleaseConnection()
if !runtime.AcquireConnection() {
t.Fatal("released connection slot was not reusable")
}
runtime.ReleaseConnection()
}
TestWSOutboundLimits
Parameters
func TestWSOutboundLimits(t *testing.T)
{
defaults := DefaultSSCLimits()
if defaults.WriteTimeout != 10*time.Second || defaults.OutboundQueueSize != 64 {
t.Fatalf("unexpected outbound defaults: timeout=%s queue=%d", defaults.WriteTimeout, defaults.OutboundQueueSize)
}
runtime := NewWSRuntime(WithSSCLimits(SSCLimits{
WriteTimeout: 250 * time.Millisecond,
OutboundQueueSize: 8,
}))
if runtime.limits.WriteTimeout != 250*time.Millisecond || runtime.limits.OutboundQueueSize != 8 {
t.Fatalf("outbound overrides were not applied: %#v", runtime.limits)
}
}
BenchmarkStreamBusFanout
Parameters
func BenchmarkStreamBusFanout(b *testing.B)
{
for _, subscribers := range []int{1, 10, 100} {
b.Run(fmt.Sprintf("subscribers-%d", subscribers), func(b *testing.B) {
bus := streambus.NewInMemory(streambus.Config{DefaultBuffer: 4096, MaxBuffer: 4096})
defer func() { _ = bus.Close() }()
topics := make([]string, subscribers)
for i := 0; i < subscribers; i++ {
topics[i] = fmt.Sprintf("rfw/connection/%d", i)
subscription, err := bus.Subscribe(context.Background(), streambus.SubscribeOptions{
Topic: topics[i], Buffer: 4096, Overflow: streambus.Block,
})
if err != nil {
b.Fatal(err)
}
defer func() { _ = subscription.Close() }()
go func() {
for range subscription.Frames() {
continue
}
}()
}
outbound := Outbound{Component: "Chart", Payload: map[string]any{"value": 42}}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, topic := range topics {
payload, err := json.Marshal(outbound)
if err != nil {
b.Fatal(err)
}
if _, err := bus.Publish(context.Background(), streambus.Frame{
Topic: topic, Payload: payload, Reliability: streambus.Reliable,
Priority: streambus.PriorityInteractive,
}); err != nil {
b.Fatal(err)
}
}
}
})
}
}
BenchmarkWebSocketFanout
Parameters
func BenchmarkWebSocketFanout(b *testing.B)
{
for _, subscribers := range []int{1, 10, 100} {
b.Run(fmt.Sprintf("subscribers-%d", subscribers), func(b *testing.B) {
accepted := make(chan *websocket.Conn, subscribers)
stop := make(chan struct{})
server := httptest.NewServer(websocket.Handler(func(connection *websocket.Conn) {
accepted <- connection
<-stop
}))
defer server.Close()
serverConnections := make([]*websocket.Conn, 0, subscribers)
clientConnections := make([]*websocket.Conn, 0, subscribers)
for i := 0; i < subscribers; i++ {
client, err := websocket.Dial("ws"+strings.TrimPrefix(server.URL, "http"), "", server.URL)
if err != nil {
b.Fatal(err)
}
clientConnections = append(clientConnections, client)
serverConnections = append(serverConnections, <-accepted)
go func() {
for {
var payload []byte
if websocket.Message.Receive(client, &payload) != nil {
return
}
}
}()
}
defer func() {
for _, connection := range clientConnections {
_ = connection.Close()
}
for _, connection := range serverConnections {
ForgetConnection(connection)
}
close(stop)
}()
outbound := Outbound{Component: "Chart", Payload: map[string]any{"value": 42}}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, connection := range serverConnections {
SendOutbound(connection, outbound)
}
}
})
}
}
Handler
Handler processes inbound payloads for a HostComponent and returns a
response payload to send back to the wasm runtime. Returning nil results in
no message being sent.
type Handler func(payload map[string]any) any
HandlerWithSession
HandlerWithSession processes inbound payloads with the associated Session.
type HandlerWithSession func(*Session, map[string]any) any
HostComponent
HostComponent represents server-side logic backing an HTML component.
type HostComponent struct
Methods
WithInitSnapshot registers a callback that produces an InitSnapshot when a resync is requested.
Parameters
Returns
func (*HostComponent) WithInitSnapshot(fn func(*Session, map[string]any) *InitSnapshot) *HostComponent
{
hc.initSnapshot = fn
return hc
}
Name returns the registered component name.
Returns
func (*HostComponent) Name() string
{ return hc.name }
Handle executes the component's handler.
Parameters
Returns
func (*HostComponent) Handle(payload map[string]any) any
{
if hc.handler != nil {
return hc.handler(payload)
}
return nil
}
HandleWithSession executes the session-aware handler when available.
Parameters
Returns
func (*HostComponent) HandleWithSession(session *Session, payload map[string]any) any
{
if payload != nil {
if _, ok := payload["resync"]; ok && hc.initSnapshot != nil {
if snap := hc.initSnapshot(session, payload); snap != nil {
return snap
}
}
}
if hc.sessionHandler != nil {
return hc.sessionHandler(session, payload)
}
if hc.handler != nil {
return hc.handler(payload)
}
return nil
}
SessionAware reports whether the component registered a session handler.
Returns
func (*HostComponent) SessionAware() bool
{ return hc.sessionHandler != nil }
StoreManager returns the session-specific store manager when available. If session is nil a reference to the global manager is returned for backward compatibility with legacy handlers.
Parameters
Returns
func (*HostComponent) StoreManager(session *Session) *state.StoreManager
{
if session != nil {
return session.StoreManager()
}
return state.GlobalStoreManager
}
Fields
| Name | Type | Description |
|---|---|---|
| name | string | |
| handler | Handler | |
| sessionHandler | HandlerWithSession | |
| initSnapshot | func(*Session, map[string]any) *InitSnapshot |
ServerComponent
ServerComponent is the concise name for HostComponent.
type ServerComponent HostComponent
InitSnapshot
InitSnapshot represents markup the host can send to force the client to
repaint a fragment. HTML is injected into the component root as raw HTML on
the client, so build it from the escaping helpers (Span, Div, P, Tag); any
unescaped user-derived data in it is an XSS vector. Use Raw/RawTag only for
markup you generated or sanitized yourself.
type InitSnapshot struct
Fields
| Name | Type | Description |
|---|---|---|
| HTML | string | json:"html" |
| Vars | []string | json:"vars,omitempty" |
NewHostComponent
NewHostComponent registers a handler for the given component name.
Parameters
Returns
func NewHostComponent(name string, handler Handler) *HostComponent
{
hc := &HostComponent{name: name, handler: handler}
if handler != nil {
hc.sessionHandler = func(_ *Session, payload map[string]any) any {
return handler(payload)
}
}
return hc
}
Uses
NewHostComponentWithSession
NewHostComponentWithSession registers a session-aware handler.
Parameters
Returns
func NewHostComponentWithSession(name string, handler HandlerWithSession) *HostComponent
{
return &HostComponent{name: name, sessionHandler: handler}
}
Component
Component is the interface for struct-based host components.
Register a struct implementing Component via RegisterComponent.
type Component interface
RegisterComponent
RegisterComponent creates a HostComponent from a struct implementing Component
and registers it in the global registry. This is the recommended way to
define host components — it provides type safety and clean separation.
Parameters
func RegisterComponent(c Component)
{
hc := &HostComponent{
name: c.Name(),
sessionHandler: func(s *Session, p map[string]any) any { return c.Serve(s, p) },
}
registryMu.Lock()
registry[c.Name()] = hc
registryMu.Unlock()
}
Uses
Register
Register adds a HostComponent to the global registry so incoming messages
can be routed to it.
Parameters
func Register(hc *HostComponent)
{
registryMu.Lock()
registry[hc.name] = hc
registryMu.Unlock()
}
Get
Get returns a registered HostComponent by name.
Parameters
Returns
func Get(name string) (*HostComponent, bool)
{
registryMu.RLock()
hc, ok := registry[name]
registryMu.RUnlock()
return hc, ok
}
Inbound
Inbound is a client-to-host SSC protocol message.
type Inbound struct
Fields
| Name | Type | Description |
|---|---|---|
| Component | string | json:"component,omitempty" |
| Action | string | json:"action,omitempty" |
| Control | string | json:"control,omitempty" |
| ID | string | json:"id,omitempty" |
| Payload | map[string]any | json:"payload,omitempty" |
| Sequence | uint64 | json:"sequence,omitempty" |
| Ack | uint64 | json:"ack,omitempty" |
| ResumeToken | string | json:"resumeToken,omitempty" |
Outbound
Outbound is a host-to-client SSC protocol message.
type Outbound struct
Fields
| Name | Type | Description |
|---|---|---|
| Component | string | json:"component,omitempty" |
| Action | string | json:"action,omitempty" |
| Control | string | json:"control,omitempty" |
| ID | string | json:"id,omitempty" |
| Payload | any | json:"payload,omitempty" |
| Error | *ActionError | json:"error,omitempty" |
| Session | string | json:"session,omitempty" |
| Sequence | uint64 | json:"sequence,omitempty" |
| Ack | uint64 | json:"ack,omitempty" |
| ResumeToken | string | json:"resumeToken,omitempty" |
| encodedPayload | []byte |
ActionError
ActionError is a public, machine-readable action failure.
type ActionError struct
Methods
Returns
func (*ActionError) Error() string
{
if e == nil {
return ""
}
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
Fields
| Name | Type | Description |
|---|---|---|
| Code | string | json:"code" |
| Message | string | json:"message" |
| Fields | map[string]string | json:"fields,omitempty" |
NewActionError
NewActionError creates a public action error safe to return to the client.
Parameters
Returns
func NewActionError(code, message string) *ActionError
{
return &ActionError{Code: code, Message: message}
}
Transport
Transport identifies the browser-to-host transport used by SSC.
type Transport string
ResolveTransport
ResolveTransport reads RFW_TRANSPORT first, then the top-level transport
key in rfw.json. Unknown and empty values preserve the WebSocket default.
Returns
func ResolveTransport() Transport
{
if mode := normalizeTransport(os.Getenv("RFW_TRANSPORT")); mode != "" {
return mode
}
var manifest struct {
Transport string `json:"transport"`
}
if data, err := os.ReadFile("rfw.json"); err == nil {
if json.Unmarshal(data, &manifest) == nil {
if mode := normalizeTransport(manifest.Transport); mode != "" {
return mode
}
}
}
return TransportWebSocket
}
Uses
normalizeTransport
Parameters
Returns
func normalizeTransport(value string) Transport
{
switch strings.ToLower(strings.TrimSpace(value)) {
case string(TransportWebSocket), "ws":
return TransportWebSocket
case string(TransportStreamBus), "webtransport", "warp-streambus":
return TransportStreamBus
case string(TransportAuto):
return TransportAuto
default:
return ""
}
}
Uses
streamBusEnabled
Returns
func streamBusEnabled() bool
{
mode := ResolveTransport()
return mode == TransportStreamBus || mode == TransportAuto
}
writeFrame
Parameters
Returns
func writeFrame(w io.Writer, payload []byte) error
{
var prefix [binary.MaxVarintLen64]byte
n := binary.PutUvarint(prefix[:], uint64(len(payload)))
if err := writeAll(w, prefix[:n]); err != nil {
return err
}
return writeAll(w, payload)
}
readFrame
Parameters
Returns
func readFrame(r *bufio.Reader, maximum int) ([]byte, error)
{
size, err := binary.ReadUvarint(r)
if err != nil {
return nil, err
}
if maximum > 0 && size > uint64(maximum) {
return nil, fmt.Errorf("host: frame length %d exceeds %d", size, maximum)
}
if size > uint64(^uint(0)>>1) {
return nil, fmt.Errorf("host: frame length %d exceeds platform limit", size)
}
payload := make([]byte, size)
if _, err := io.ReadFull(r, payload); err != nil {
return nil, err
}
return payload, nil
}
writeAll
Parameters
Returns
func writeAll(w io.Writer, payload []byte) error
{
for len(payload) > 0 {
n, err := w.Write(payload)
if err != nil {
return err
}
if n <= 0 {
return io.ErrShortWrite
}
payload = payload[n:]
}
return nil
}
logLevel
Returns
func logLevel() slog.Level
{
switch strings.ToLower(os.Getenv("RFW_LOG_LEVEL")) {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
openProtocolSocket
Parameters
Returns
func openProtocolSocket(t *testing.T, opts ...MuxOption) (*websocket.Conn, func())
{
t.Helper()
server := httptest.NewServer(NewMux(t.TempDir(), opts...))
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws"
socket, err := websocket.Dial(wsURL, "", server.URL)
if err != nil {
server.Close()
t.Fatalf("dial websocket: %v", err)
}
return socket, func() {
closeTestResource(t, socket)
server.Close()
}
}
sendProtocolMessage
Parameters
func sendProtocolMessage(t *testing.T, socket *websocket.Conn, message Inbound)
{
t.Helper()
data, err := json.Marshal(message)
if err != nil {
t.Fatalf("marshal message: %v", err)
}
if err := websocket.Message.Send(socket, data); err != nil {
t.Fatalf("send message: %v", err)
}
}
Uses
receiveProtocolMessage
Parameters
Returns
func receiveProtocolMessage(t *testing.T, socket *websocket.Conn) Outbound
{
t.Helper()
var data []byte
if err := websocket.Message.Receive(socket, &data); err != nil {
t.Fatalf("receive message: %v", err)
}
var message Outbound
if err := json.Unmarshal(data, &message); err != nil {
t.Fatalf("decode message: %v", err)
}
return message
}
Uses
TestWSTypedActionRejectsUnknownFields
Parameters
func TestWSTypedActionRejectsUnknownFields(t *testing.T)
{
type request struct {
Value int `json:"value"`
}
const action = "test.ws.strict"
if err := RegisterAction(action, func(_ context.Context, _ *Session, request request) (request, error) {
return request, nil
}); err != nil {
t.Fatalf("register action: %v", err)
}
socket, closeSocket := openProtocolSocket(t)
defer closeSocket()
sendProtocolMessage(t, socket, Inbound{
Action: action,
ID: "strict",
Sequence: 1,
Payload: map[string]any{"value": 1, "admin": true},
})
response := receiveProtocolMessage(t, socket)
if response.ID != "strict" || response.Error == nil || response.Error.Code != "invalid_request" {
t.Fatalf("unexpected strict response: %#v", response)
}
}
TestWSMessageAuthorizationRunsBeforeAction
Parameters
func TestWSMessageAuthorizationRunsBeforeAction(t *testing.T)
{
type request struct {
Value int `json:"value"`
}
const action = "test.ws.authorized"
called := false
if err := RegisterAction(action, func(_ context.Context, _ *Session, request request) (request, error) {
called = true
return request, nil
}); err != nil {
t.Fatalf("register action: %v", err)
}
socket, closeSocket := openProtocolSocket(t, WithSSCAuthorizer(func(_ context.Context, _ *Session, message Inbound) error {
if message.Action == action {
return errors.New("denied")
}
return nil
}))
defer closeSocket()
sendProtocolMessage(t, socket, Inbound{Action: action, ID: "denied", Sequence: 1})
response := receiveProtocolMessage(t, socket)
if response.Error == nil || response.Error.Code != "forbidden" || called {
t.Fatalf("authorization failed closed incorrectly: response=%#v called=%v", response, called)
}
}
TestWSRateLimitRejectsExcessMessages
Parameters
func TestWSRateLimitRejectsExcessMessages(t *testing.T)
{
type request struct{}
const action = "test.ws.rate"
if err := RegisterAction(action, func(_ context.Context, _ *Session, _ request) (string, error) {
return "ok", nil
}); err != nil {
t.Fatalf("register action: %v", err)
}
socket, closeSocket := openProtocolSocket(t, WithSSCLimits(SSCLimits{MessagesPerMinute: 1}))
defer closeSocket()
sendProtocolMessage(t, socket, Inbound{Action: action, ID: "first", Sequence: 1})
if response := receiveProtocolMessage(t, socket); response.Error != nil {
t.Fatalf("first message rejected: %#v", response)
}
sendProtocolMessage(t, socket, Inbound{Action: action, ID: "second", Sequence: 2})
response := receiveProtocolMessage(t, socket)
if response.Error == nil || response.Error.Code != "rate_limited" {
t.Fatalf("rate limit did not reject second message: %#v", response)
}
if response.Ack != 2 {
t.Fatalf("rate-limited message was not acknowledged: %#v", response)
}
}
TestWSActionTimeout
Parameters
func TestWSActionTimeout(t *testing.T)
{
type request struct{}
const action = "test.ws.timeout"
if err := RegisterAction(action, func(_ context.Context, _ *Session, _ request) (string, error) {
time.Sleep(50 * time.Millisecond)
return "late", nil
}); err != nil {
t.Fatalf("register action: %v", err)
}
socket, closeSocket := openProtocolSocket(t, WithSSCLimits(SSCLimits{HandlerTimeout: 5 * time.Millisecond}))
defer closeSocket()
sendProtocolMessage(t, socket, Inbound{Action: action, ID: "timeout", Sequence: 1})
response := receiveProtocolMessage(t, socket)
if response.Error == nil || response.Error.Code != "action_timeout" {
t.Fatalf("slow action did not time out: %#v", response)
}
}
TestWSActionPanicReturnsPublicError
Parameters
func TestWSActionPanicReturnsPublicError(t *testing.T)
{
type request struct{}
const action = "test.ws.panic"
if err := RegisterAction(action, func(_ context.Context, _ *Session, _ request) (string, error) {
panic("private handler detail")
}); err != nil {
t.Fatalf("register action: %v", err)
}
socket, closeSocket := openProtocolSocket(t)
defer closeSocket()
sendProtocolMessage(t, socket, Inbound{Action: action, ID: "panic", Sequence: 1})
response := receiveProtocolMessage(t, socket)
if response.Error == nil || response.Error.Code != "action_failed" || response.Error.Message != "action failed" {
t.Fatalf("panic details crossed the protocol: %#v", response)
}
}
TestWSRejectsOversizedFrame
Parameters
func TestWSRejectsOversizedFrame(t *testing.T)
{
socket, closeSocket := openProtocolSocket(t, WithSSCLimits(SSCLimits{MaxMessageBytes: 128}))
defer closeSocket()
data, err := json.Marshal(Inbound{
Component: "oversized",
Sequence: 1,
Payload: map[string]any{"value": strings.Repeat("x", 512)},
})
if err != nil {
t.Fatalf("marshal oversized message: %v", err)
}
if err := websocket.Message.Send(socket, data); err != nil {
t.Fatalf("send oversized message: %v", err)
}
var response []byte
if err := websocket.Message.Receive(socket, &response); err == nil {
t.Fatalf("oversized frame was accepted: %s", response)
}
}
TestWSSessionResumeReplaysUnacknowledgedResponse
Parameters
func TestWSSessionResumeReplaysUnacknowledgedResponse(t *testing.T)
{
type request struct{}
type response struct {
Count int `json:"count"`
}
const action = "test.ws.resume"
if err := RegisterAction(action, func(_ context.Context, session *Session, _ request) (response, error) {
count := 0
if stored, ok := session.ContextGet("count"); ok {
count = stored.(int)
}
count++
session.ContextSet("count", count)
return response{Count: count}, nil
}); err != nil {
t.Fatalf("register action: %v", err)
}
sessionMu.RLock()
maxSessions := len(sessions) + 1
sessionMu.RUnlock()
server := httptest.NewServer(NewMux(t.TempDir(), WithSSCLimits(SSCLimits{
ResumeTTL: time.Second,
ReplayMessages: 8,
MaxSessions: maxSessions,
})))
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws"
dial := func() *websocket.Conn {
socket, err := websocket.Dial(wsURL, "", server.URL)
if err != nil {
t.Fatalf("dial websocket: %v", err)
}
return socket
}
firstSocket := dial()
sendProtocolMessage(t, firstSocket, Inbound{Action: action, ID: "one", Sequence: 1})
first := receiveProtocolMessage(t, firstSocket)
sendProtocolMessage(t, firstSocket, Inbound{Action: action, ID: "two", Sequence: 2, Ack: first.Sequence})
second := receiveProtocolMessage(t, firstSocket)
token := second.ResumeToken
sessionID := second.Session
closeTestResource(t, firstSocket)
var (
secondSocket *websocket.Conn
replayed Outbound
current Outbound
)
deadline := time.Now().Add(time.Second)
for {
secondSocket = dial()
sendProtocolMessage(t, secondSocket, Inbound{
Action: action,
ID: "three",
Sequence: 3,
Ack: first.Sequence,
ResumeToken: token,
})
replayed = receiveProtocolMessage(t, secondSocket)
if replayed.Session == sessionID {
current = receiveProtocolMessage(t, secondSocket)
break
}
closeTestResource(t, secondSocket)
if time.Now().After(deadline) {
t.Fatalf("session did not become resumable: %#v", replayed)
}
time.Sleep(10 * time.Millisecond)
}
defer closeTestResource(t, secondSocket)
if replayed.Sequence != second.Sequence || replayed.ID != "two" {
t.Fatalf("unexpected replay: %#v", replayed)
}
if current.Session != sessionID || current.ID != "three" {
t.Fatalf("session did not resume: %#v", current)
}
payload, ok := current.Payload.(map[string]any)
if !ok || payload["count"] != float64(3) {
t.Fatalf("session state was not retained: %#v", current.Payload)
}
if session, ok := SessionByID(sessionID); ok {
ReleaseSession(session)
}
}
TestWSSessionResumeExpires
Parameters
func TestWSSessionResumeExpires(t *testing.T)
{
type request struct{}
const action = "test.ws.expiry"
if err := RegisterAction(action, func(_ context.Context, _ *Session, _ request) (string, error) {
return "ok", nil
}); err != nil {
t.Fatalf("register action: %v", err)
}
server := httptest.NewServer(NewMux(t.TempDir(), WithSSCLimits(SSCLimits{ResumeTTL: 10 * time.Millisecond})))
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws"
first, err := websocket.Dial(wsURL, "", server.URL)
if err != nil {
t.Fatalf("dial first socket: %v", err)
}
sendProtocolMessage(t, first, Inbound{Action: action, ID: "first", Sequence: 1})
response := receiveProtocolMessage(t, first)
token := response.ResumeToken
closeTestResource(t, first)
time.Sleep(40 * time.Millisecond)
second, err := websocket.Dial(wsURL, "", server.URL)
if err != nil {
t.Fatalf("dial second socket: %v", err)
}
defer closeTestResource(t, second)
sendProtocolMessage(t, second, Inbound{
Action: action,
ID: "second",
Sequence: 2,
ResumeToken: token,
})
rejected := receiveProtocolMessage(t, second)
if rejected.Control != "resume_rejected" || rejected.Error == nil {
t.Fatalf("expired session resumed: %#v", rejected)
}
}
TestWSResumeAuthorizationRejectsForeignToken
A detached session is reattached only for a request the resume authorizer
approves. The refused client is served a new session and the retained one
stays available to the identity that created it.
Parameters
func TestWSResumeAuthorizationRejectsForeignToken(t *testing.T)
{
type request struct{}
const action = "test.ws.resume.identity"
if err := RegisterAction(action, func(_ context.Context, session *Session, _ request) (string, error) {
user, _ := session.ContextGet("user")
text, _ := user.(string)
return text, nil
}); err != nil {
t.Fatalf("register action: %v", err)
}
server := httptest.NewServer(NewMux(t.TempDir(),
WithSSCLimits(SSCLimits{ResumeTTL: 5 * time.Second, ReplayMessages: 8}),
WithSSCSessionInitializer(bindResumeIdentity),
WithSSCResumeAuthorizer(authorizeSameUserResume),
))
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws"
dial := func(user string) *websocket.Conn {
config, err := websocket.NewConfig(wsURL, server.URL)
if err != nil {
t.Fatalf("websocket config: %v", err)
}
config.Header.Set(resumeIdentityHeader, user)
socket, err := websocket.DialConfig(config)
if err != nil {
t.Fatalf("dial websocket: %v", err)
}
return socket
}
ownerSocket := dial("alice")
sendProtocolMessage(t, ownerSocket, Inbound{Action: action, ID: "alice", Sequence: 1})
owner := receiveProtocolMessage(t, ownerSocket)
if owner.Payload != "alice" || owner.ResumeToken == "" {
t.Fatalf("session identity was not bound: %#v", owner)
}
closeTestResource(t, ownerSocket)
intruderSocket := dial("mallory")
defer closeTestResource(t, intruderSocket)
sendProtocolMessage(t, intruderSocket, Inbound{
Action: action,
ID: "mallory",
Sequence: 1,
Ack: owner.Sequence,
ResumeToken: owner.ResumeToken,
})
rejected := receiveProtocolMessage(t, intruderSocket)
if rejected.Control != "resume_rejected" || rejected.Error == nil {
t.Fatalf("foreign token was not refused: %#v", rejected)
}
if rejected.Session == owner.Session {
t.Fatalf("refused client attached the retained session: %#v", rejected)
}
response := receiveProtocolMessage(t, intruderSocket)
if response.Payload != "mallory" || response.Session == owner.Session {
t.Fatalf("refused client did not get its own session: %#v", response)
}
var (
resumedSocket *websocket.Conn
restored Outbound
)
deadline := time.Now().Add(2 * time.Second)
for {
resumedSocket = dial("alice")
sendProtocolMessage(t, resumedSocket, Inbound{
Action: action,
ID: "resume",
Sequence: 2,
Ack: owner.Sequence,
ResumeToken: owner.ResumeToken,
})
restored = receiveProtocolMessage(t, resumedSocket)
if restored.Session == owner.Session {
break
}
closeTestResource(t, resumedSocket)
if time.Now().After(deadline) {
t.Fatalf("owner could not resume after the refused attempt: %#v", restored)
}
time.Sleep(10 * time.Millisecond)
}
defer closeTestResource(t, resumedSocket)
if restored.ID != "resume" || restored.Payload != "alice" {
t.Fatalf("resumed session lost its identity: %#v", restored)
}
if session, ok := SessionByID(owner.Session); ok {
ReleaseSession(session)
}
}
TestWSControlPingIsAnsweredOutOfBand
Parameters
func TestWSControlPingIsAnsweredOutOfBand(t *testing.T)
{
socket, closeSocket := openProtocolSocket(t)
defer closeSocket()
sendProtocolMessage(t, socket, Inbound{Control: "ping"})
pong := receiveProtocolMessage(t, socket)
if pong.Control != "pong" {
t.Fatalf("unexpected control response: %#v", pong)
}
if pong.Sequence != 0 || pong.Session != "" {
t.Fatalf("pong carried delivery metadata: %#v", pong)
}
sendProtocolMessage(t, socket, Inbound{Component: "unregistered", Sequence: 1})
next := receiveProtocolMessage(t, socket)
if next.Sequence != 1 {
t.Fatalf("pong consumed an outbound sequence: %#v", next)
}
}
wasmEncoding
wasmEncoding pairs a content coding with the suffix of the artifact that
carries it. The order is the server’s preference, best first.
type wasmEncoding struct
Fields
| Name | Type | Description |
|---|---|---|
| name | string | |
| ext | string |
negotiateWasm
negotiateWasm answers a request for the raw bundle with the best encoding
the client accepts and the build actually produced. Serving the compressed
artifact under the raw URL is what lets one URL work for every client: the
browser advertises what it can decode, and no application configuration has
to guess.
It reports whether it wrote the response.
Parameters
Returns
func negotiateWasm(w http.ResponseWriter, r *http.Request, dir http.Dir, urlPath string) bool
{
if !strings.HasSuffix(urlPath, ".wasm") {
return false
}
accepted := acceptedEncodings(r.Header.Get("Accept-Encoding"))
if len(accepted) == 0 {
return false
}
encodings := append([]wasmEncoding(nil), wasmEncodings...)
sort.SliceStable(encodings, func(i, j int) bool {
return encodingQuality(accepted, encodings[i].name) > encodingQuality(accepted, encodings[j].name)
})
for _, encoding := range encodings {
if encodingQuality(accepted, encoding.name) <= 0 {
continue
}
file, size, ok := openArtifact(dir, urlPath+encoding.ext)
if !ok {
continue
}
defer func() {
_ = file.Close()
}()
header := w.Header()
header.Set("Content-Type", "application/wasm")
header.Set("Content-Encoding", encoding.name)
// The length of what actually goes on the wire. A client that measures
// progress against it is measuring compressed bytes, which is why the
// loader treats an encoded response as indeterminate.
header.Set("Content-Length", strconv.FormatInt(size, 10))
addVaryAcceptEncoding(header)
setWasmCacheControl(header, r.URL.Query().Get("v") != "")
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return true
}
if _, err := io.Copy(w, file); err != nil {
logger.Warn("wasm negotiation write failed", "path", urlPath, "error", err)
}
return true
}
return false
}
openArtifact
openArtifact opens a compressed bundle and reports its size on disk.
func openArtifact(dir http.Dir, name string) (http.File, int64, bool)
{
file, err := dir.Open(filepath.ToSlash(name))
if err != nil {
return nil, 0, false
}
info, err := file.Stat()
if err != nil || info.IsDir() {
_ = file.Close()
return nil, 0, false
}
return file, info.Size(), true
}
acceptedEncodings
acceptedEncodings parses an Accept-Encoding header into coding qualities.
Invalid q values reject that coding rather than accidentally preferring it.
Parameters
Returns
func acceptedEncodings(header string) map[string]float64
{
if strings.TrimSpace(header) == "" {
return nil
}
accepted := map[string]float64{}
for _, part := range strings.Split(header, ",") {
name, params, _ := strings.Cut(strings.TrimSpace(part), ";")
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
continue
}
quality := 1.0
if parsed, present, valid := parseQuality(params); present {
if !valid {
quality = 0
} else {
quality = parsed
}
}
accepted[name] = quality
}
return accepted
}
encodingQuality
Parameters
Returns
func encodingQuality(accepted map[string]float64, name string) float64
{
if quality, ok := accepted[name]; ok {
return quality
}
return accepted["*"]
}
parseQuality
parseQuality reads the q value out of an Accept-Encoding parameter list.
Parameters
Returns
func parseQuality(params string) (quality float64, present bool, valid bool)
{
for _, param := range strings.Split(params, ";") {
key, value, found := strings.Cut(strings.TrimSpace(param), "=")
if !found || !strings.EqualFold(strings.TrimSpace(key), "q") {
continue
}
quality, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err != nil || quality < 0 || quality > 1 {
return 0, true, false
}
return quality, true, true
}
return 0, false, false
}
addVaryAcceptEncoding
addVaryAcceptEncoding records that the response body depends on the request
encoding, so a shared cache cannot hand a brotli body to a client that
cannot decode it.
Parameters
func addVaryAcceptEncoding(header http.Header)
{
vary := header.Get("Vary")
switch {
case vary == "":
header.Set("Vary", "Accept-Encoding")
case !strings.Contains(vary, "Accept-Encoding"):
header.Set("Vary", vary+", Accept-Encoding")
}
}
revalidates
revalidates reports whether a document has to be checked with the server on
every load. Both entries here carry pointers to versioned assets:
index.html holds the stamped bootstrap script tags and rfw_config.js holds
the wasm version. Caching either one lets a browser keep following an old
pointer, so a release could never replace the loader or the bundle.
Parameters
Returns
func revalidates(path string) bool
{
switch strings.Trim(path, "/") {
case "index.html", "rfw_config.js":
return true
default:
return false
}
}
setWasmCacheControl
setWasmCacheControl applies the caching policy for a bundle. A URL that
carries a content version is immutable; one that does not has to be
revalidated or a release cannot replace it.
Parameters
func setWasmCacheControl(header http.Header, versioned bool)
{
if devMode() {
header.Set("Cache-Control", "no-store")
return
}
if versioned {
header.Set("Cache-Control", "public, max-age=31536000, immutable")
return
}
header.Set("Cache-Control", "no-cache")
}
artifactEncoding
artifactEncoding reports the coding a directly requested artifact carries.
Parameters
Returns
func artifactEncoding(path string) (string, bool)
{
if !strings.Contains(path, ".wasm") {
return "", false
}
for _, encoding := range wasmEncodings {
if strings.HasSuffix(path, ".wasm"+encoding.ext) {
return encoding.name, true
}
}
return "", false
}
wasmRequest
wasmRequest reports whether path addresses a bundle in any encoding.
Parameters
Returns
func wasmRequest(path string) bool
{
if strings.HasSuffix(path, ".wasm") {
return true
}
_, ok := artifactEncoding(path)
return ok
}
validTagName
Parameters
Returns
func validTagName(tag string) bool
{
if tag == "" || !isASCIILetter(rune(tag[0])) {
return false
}
for _, char := range tag {
if isASCIILetter(char) || char >= '0' && char <= '9' || char == '-' {
continue
}
return false
}
return true
}
isASCIILetter
Parameters
Returns
func isASCIILetter(char rune) bool
{
return char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z'
}
hostVarTag
hostVarTag builds a host variable element. The value is HTML-escaped so
user-derived data cannot inject markup through the initial snapshot.
Parameters
Returns
func hostVarTag(tag, name string, value any, escape bool) string
{
if !validTagName(tag) {
return ""
}
v := fmt.Sprintf("%v", value)
body := v
expected := ""
if escape {
body = html.EscapeString(v)
expected = html.EscapeString(v)
}
return fmt.Sprintf(`<%s %s="%s" %s="%s">%s</%s>`,
tag, hostVarAttr, html.EscapeString(name), hostExpectedAttr, expected, body, tag)
}
Span
Span renders an escaped host variable in a span.
Parameters
Returns
func Span(name string, value any) string
{
return hostVarTag("span", name, value, true)
}
Div
Div renders an escaped host variable in a div.
Parameters
Returns
func Div(name string, value any) string
{
return hostVarTag("div", name, value, true)
}
P
P renders an escaped host variable in a paragraph.
Parameters
Returns
func P(name string, value any) string
{
return hostVarTag("p", name, value, true)
}
Tag
Tag renders an escaped host variable with tag.
Parameters
Returns
func Tag(tag, name string, value any) string
{
return hostVarTag(tag, name, value, true)
}
RawTag
RawTag builds a host variable element without escaping the value. It is the
explicit trust API for markup values: only pass HTML you generated or
sanitized yourself, never user-derived data.
Parameters
Returns
func RawTag(tag, name string, value any) string
{
return hostVarTag(tag, name, value, false)
}
Raw
Raw marks a fragment as trusted HTML and returns it unchanged. It exists to
make raw injection points explicit at call sites: anything passed through
Raw ends up in the client DOM unescaped via InitSnapshot.HTML.
Parameters
Returns
func Raw(html string) string
{
return html
}
Join
Join concatenates rendered host fragments.
Parameters
Returns
func Join(parts ...string) string
{
var b strings.Builder
for _, p := range parts {
b.WriteString(p)
}
return b.String()
}