ssc API

ssc

package

API reference for the ssc package.

F
function

closeTestResource

Parameters

resource
ssc/close_test.go:10-15
func closeTestResource(t *testing.T, resource io.Closer)

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

SSCEvent

SSCEvent carries a component message and its session.

ssc/server.go:23-27
type SSCEvent struct

Fields

Name Type Description
Component string
Payload map[string]any
Session *host.Session
T
type

Event

Event is the concise name for SSCEvent.

ssc/server.go:30-30
type Event SSCEvent
F
function

SubscribeSSC

SubscribeSSC registers an SSC event handler.

Parameters

priority
...fnevents.Priority
ssc/server.go:38-40
func SubscribeSSC(fn fnevents.Handler[SSCEvent], priority ...fnevents.Priority)

{
	fnevents.Subscribe[SSCEvent](bus, fn, priority...)
}
F
function

EmitSSC

EmitSSC emits an SSC event synchronously.

Parameters

Returns

error
ssc/server.go:43-45
func EmitSSC(ctx context.Context, event SSCEvent) error

{
	return fnevents.Emit(ctx, bus, event)
}
S
struct

SSCServer

SSCServer serves static assets and SSC WebSocket traffic.

ssc/server.go:48-54
type SSCServer struct

Methods

buildMux
Method

Returns

func (*SSCServer) buildMux() *http.ServeMux
{
	mux := http.NewServeMux()
	runtime := host.NewWSRuntime(s.opts...)
	root := host.ResolveRoot(s.Root)
	staticRoot := filepath.Join(root, "..", "static")
	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) {
			setWasmHeaders(w, r.URL.Path, r.URL.Query().Get("v") != "")
			sfs.ServeHTTP(w, r)
		})))
	}
	wsGuarded := runtime.Guard(websocket.Handler(func(ws *websocket.Conn) {
		wsHandler(ws, runtime)
	}))
	mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
		wsGuarded.ServeHTTP(w, r)
	})
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		if os.Getenv("RFW_DEV_BUILD") == "1" {
			w.Header().Set("Cache-Control", "no-store")
		}
		if sfs != nil {
			if regularFile(staticDir, r.URL.Path) {
				setWasmHeaders(w, r.URL.Path, r.URL.Query().Get("v") != "")
				sfs.ServeHTTP(w, r)
				return
			}
		}
		if regularFile(rootDir, r.URL.Path) {
			setWasmHeaders(w, r.URL.Path, r.URL.Query().Get("v") != "")
			fs.ServeHTTP(w, r)
			return
		}
		if strings.HasSuffix(r.URL.Path, ".wasm") || strings.HasSuffix(r.URL.Path, ".wasm.br") {
			http.NotFound(w, r)
			return
		}
		http.ServeFile(w, r, filepath.Join(root, "index.html"))
	})
	return mux
}

ListenAndServe starts the SSC HTTP server.

Returns

error
func (*SSCServer) ListenAndServe() error
{
	log.Printf("SSC server starting on %s", s.Addr)
	server := &http.Server{
		Addr:              s.Addr,
		Handler:           s.Mux,
		ReadHeaderTimeout: 5 * time.Second,
	}
	return server.ListenAndServe()
}

Fields

Name Type Description
Addr string
Root string
Mux *http.ServeMux
opts []host.MuxOption
T
type

Server

Server is the concise name for SSCServer.

ssc/server.go:57-57
type Server SSCServer
F
function

NewSSCServer

NewSSCServer builds an SSC server serving files from root and the WebSocket
endpoint at /ws. Options such as host.WithAuthFunc and
host.WithOriginAllowlist gate the WebSocket endpoint; by default it accepts
any origin and identity.

Parameters

addr
string
root
string
opts
...host.MuxOption

Returns

ssc/server.go:63-67
func NewSSCServer(addr, root string, opts ...host.MuxOption) *SSCServer

{
	s := &SSCServer{Addr: addr, Root: root, opts: opts}
	s.Mux = s.buildMux()
	return s
}
F
function

regularFile

Parameters

root
name
string

Returns

bool
ssc/server.go:130-138
func regularFile(root http.Dir, name string) bool

{
	f, err := root.Open(name)
	if err != nil {
		return false
	}
	info, statErr := f.Stat()
	closeErr := f.Close()
	return statErr == nil && closeErr == nil && !info.IsDir()
}
F
function

wsHandler

Parameters

ssc/server.go:140-277
func wsHandler(ws *websocket.Conn, runtime *host.WSRuntime)

{
	if !runtime.AcquireConnection() {
		host.SendOutbound(ws, host.Outbound{Error: host.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 *host.Session
	var subscribed []string
	subscribedSet := make(map[string]struct{})
	defer func() {
		for _, name := range subscribed {
			if m, ok := connMap.Get(name); ok {
				m.Delete(ws)
			}
		}
		host.SuspendSession(session, runtime.ResumeTTL())
		host.ForgetConnection(ws)
		if err := ws.Close(); err != nil {
			log.Printf("close websocket: %v", err)
		}
	}()

	for {
		var msg host.Inbound
		if err := websocket.JSON.Receive(ws, &msg); err != nil {
			if err != io.EOF {
				log.Printf("ws receive error: %v", err)
			}
			break
		}
		if session == nil {
			var resumed bool
			var err error
			session, resumed, err = runtime.OpenSession(ws.Request(), msg.ResumeToken)
			if err != nil {
				host.SendOutbound(ws, host.Outbound{Error: host.NewActionError("session_rejected", "session rejected")})
				return
			}
			host.BindSessionConnection(ws, session)
			if resumed {
				host.ReplaySession(ws, session, msg.Ack)
			} else if msg.ResumeToken != "" {
				host.SendSessionOutbound(ws, session, host.Outbound{
					Control: "resume_rejected",
					Error:   host.NewActionError("resume_rejected", "session could not be resumed"),
				})
			}
		}
		session.Acknowledge(msg.Ack)
		if err := session.AcceptInbound(msg.Sequence); err != nil {
			if errors.Is(err, host.ErrDuplicateMessage) {
				continue
			}
			host.SendSessionOutbound(ws, session, host.Outbound{
				Action: msg.Action,
				ID:     msg.ID,
				Error:  host.NewActionError("sequence_gap", "client message sequence gap"),
			})
			continue
		}
		if !session.AllowMessage(runtime.MessagesPerMinute()) {
			host.SendSessionOutbound(ws, session, host.Outbound{
				Action: msg.Action,
				ID:     msg.ID,
				Error:  host.NewActionError("rate_limited", "message rate limit exceeded"),
			})
			continue
		}
		authorizeCtx, cancelAuthorize := runtime.HandlerContext(context.Background())
		authorizeErr := runtime.Authorize(authorizeCtx, session, msg)
		cancelAuthorize()
		if authorizeErr != nil {
			host.SendSessionOutbound(ws, session, host.Outbound{
				Component: msg.Component,
				Action:    msg.Action,
				ID:        msg.ID,
				Error:     host.NewActionError("forbidden", "message forbidden"),
			})
			continue
		}
		if msg.Action != "" {
			payload, actionErr := runtime.DispatchAction(context.Background(), session, msg)
			host.SendSessionOutbound(ws, session, host.Outbound{
				Action:  msg.Action,
				ID:      msg.ID,
				Payload: payload,
				Error:   actionErr,
			})
			continue
		}
		name := msg.Component
		if name == "" {
			continue
		}
		m := connMap.GetOrSet(name, fnsafemap.New[*websocket.Conn, *host.Session]())
		m.Set(ws, session)
		if _, ok := subscribedSet[name]; !ok {
			subscribedSet[name] = struct{}{}
			subscribed = append(subscribed, name)
		}

		if hc, ok := host.Get(name); ok {
			resp := hc.HandleWithSession(session, msg.Payload)
			if resp != nil {
				switch v := resp.(type) {
				case *host.InitSnapshot:
					if v != nil {
						host.SendSessionOutbound(ws, session, host.Outbound{Component: name, ID: msg.ID, Payload: map[string]any{"initSnapshot": v}})
					}
					continue
				case host.InitSnapshot:
					host.SendSessionOutbound(ws, session, host.Outbound{Component: name, ID: msg.ID, Payload: map[string]any{"initSnapshot": v}})
					continue
				default:
					host.SendSessionOutbound(ws, session, host.Outbound{Component: name, ID: msg.ID, Payload: resp})
					continue
				}
			}
			if msg.Payload != nil && msg.Payload["init"] == true {
				host.SendSessionOutbound(ws, session, host.Outbound{Component: name, Payload: map[string]any{"session": session.ID()}})
			}
		}

		if err := fnevents.Emit(context.Background(), bus, Event{
			Component: name,
			Payload:   msg.Payload,
			Session:   session,
		}); err != nil {
			log.Printf("ssc event: %v", err)
		}
		host.SendSessionOutbound(ws, session, host.Outbound{Control: "ack"})
	}
}
F
function

Broadcast

Broadcast sends a payload to connected component sessions.

Parameters

component
string
payload
any
opts
...host.BroadcastOption
ssc/server.go:280-296
func Broadcast(component string, payload any, opts ...host.BroadcastOption)

{
	o := host.BroadcastOptions{Session: ""}
	for _, opt := range opts {
		opt(&o)
	}
	m, ok := connMap.Get(component)
	if !ok {
		return
	}
	m.Range(func(ws *websocket.Conn, session *host.Session) bool {
		if o.Session != "" && session.ID() != o.Session {
			return true
		}
		host.SendSessionOutbound(ws, session, host.Outbound{Component: component, Payload: payload})
		return true
	})
}
T
type

BroadcastOption

BroadcastOption configures an SSC broadcast.

ssc/server.go:299-299
type BroadcastOption host.BroadcastOption
F
function

WithSessionTarget

WithSessionTarget limits a broadcast to one session.

Parameters

sessionID
string
ssc/server.go:302-304
func WithSessionTarget(sessionID string) host.BroadcastOption

{
	return host.WithSessionTarget(sessionID)
}
F
function

setWasmHeaders

Parameters

path
string
versioned
bool
ssc/server.go:306-334
func setWasmHeaders(w http.ResponseWriter, path string, versioned bool)

{
	dev := os.Getenv("RFW_DEV_BUILD") == "1"
	if dev {
		w.Header().Set("Cache-Control", "no-store")
	} else if strings.Trim(path, "/") == "rfw_config.js" {
		w.Header().Set("Cache-Control", "no-cache")
	}
	if !strings.HasSuffix(path, ".wasm") && !strings.HasSuffix(path, ".wasm.br") {
		return
	}
	h := w.Header()
	if !dev {
		if versioned {
			h.Set("Cache-Control", "public, max-age=31536000, immutable")
		} else {
			h.Set("Cache-Control", "no-cache")
		}
	}
	if !strings.HasSuffix(path, ".wasm.br") {
		return
	}
	h.Set("Content-Encoding", "br")
	h.Set("Content-Type", "application/wasm")
	if vary := h.Get("Vary"); vary == "" {
		h.Set("Vary", "Accept-Encoding")
	} else if !strings.Contains(vary, "Accept-Encoding") {
		h.Set("Vary", vary+", Accept-Encoding")
	}
}
F
function

TestSSCEventBus

Parameters

ssc/server_test.go:20-39
func TestSSCEventBus(t *testing.T)

{
	type seenEvent struct {
		component string
		value     any
	}
	seen := make(chan seenEvent, 1)
	SubscribeSSC(func(_ context.Context, e Event) error {
		seen <- seenEvent{component: e.Component, value: e.Payload["value"]}
		return nil
	})

	if err := EmitSSC(context.Background(), Event{Component: "Counter", Payload: map[string]any{"value": 2}}); err != nil {
		t.Fatalf("emit failed: %v", err)
	}

	got := <-seen
	if got.component != "Counter" || got.value != 2 {
		t.Fatalf("unexpected event: %+v", got)
	}
}
F
function

TestSSCServerServesIndexAndWasmHeaders

Parameters

ssc/server_test.go:41-106
func TestSSCServerServesIndexAndWasmHeaders(t *testing.T)

{
	root := t.TempDir()
	if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("<main>app</main>"), 0o600); err != nil {
		t.Fatalf("write index: %v", err)
	}
	if err := os.WriteFile(filepath.Join(root, "app.wasm.br"), []byte("wasm"), 0o600); err != nil {
		t.Fatalf("write wasm: %v", err)
	}
	if err := os.WriteFile(filepath.Join(root, "rfw_config.js"), []byte("//cfg"), 0o600); err != nil {
		t.Fatalf("write runtime config: %v", err)
	}

	server := NewSSCServer(":0", root)
	ts := httptest.NewServer(server.Mux)
	defer ts.Close()

	resp, err := http.Get(ts.URL + "/docs/anything")
	if err != nil {
		t.Fatalf("index fallback request failed: %v", err)
	}
	if err := resp.Body.Close(); err != nil {
		t.Fatalf("close index response: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected index fallback 200, got %d", resp.StatusCode)
	}

	resp, err = http.Get(ts.URL + "/app.wasm.br?v=abc123")
	if err != nil {
		t.Fatalf("wasm request failed: %v", err)
	}
	if err := resp.Body.Close(); err != nil {
		t.Fatalf("close versioned wasm response: %v", err)
	}
	if resp.Header.Get("Content-Encoding") != "br" {
		t.Fatalf("expected br encoding, got %q", resp.Header.Get("Content-Encoding"))
	}
	if resp.Header.Get("Content-Type") != "application/wasm" {
		t.Fatalf("expected wasm content type, got %q", resp.Header.Get("Content-Type"))
	}
	if cache := resp.Header.Get("Cache-Control"); cache != "public, max-age=31536000, immutable" {
		t.Fatalf("unexpected Cache-Control header: %q", cache)
	}

	resp, err = http.Get(ts.URL + "/app.wasm.br?v=")
	if err != nil {
		t.Fatalf("unversioned wasm request failed: %v", err)
	}
	if err := resp.Body.Close(); err != nil {
		t.Fatalf("close unversioned wasm response: %v", err)
	}
	if cache := resp.Header.Get("Cache-Control"); cache != "no-cache" {
		t.Fatalf("unexpected unversioned Cache-Control header: %q", cache)
	}

	resp, err = http.Get(ts.URL + "/rfw_config.js")
	if err != nil {
		t.Fatalf("runtime config request failed: %v", err)
	}
	if err := resp.Body.Close(); err != nil {
		t.Fatalf("close runtime config response: %v", err)
	}
	if cache := resp.Header.Get("Cache-Control"); cache != "no-cache" {
		t.Fatalf("unexpected runtime config Cache-Control header: %q", cache)
	}
}
F
function

TestSSCWithSessionTargetDelegatesHostOption

Parameters

ssc/server_test.go:108-114
func TestSSCWithSessionTargetDelegatesHostOption(t *testing.T)

{
	var opts host.BroadcastOptions
	WithSessionTarget("abc")(&opts)
	if opts.Session != "abc" {
		t.Fatalf("expected session abc, got %q", opts.Session)
	}
}
F
function

TestSSCServerDevModeDoesNotCacheAssets

Parameters

ssc/server_test.go:116-143
func TestSSCServerDevModeDoesNotCacheAssets(t *testing.T)

{
	t.Setenv("RFW_DEV_BUILD", "1")
	root := t.TempDir()
	for name, contents := range map[string]string{
		"index.html":    "<main>app</main>",
		"app.wasm":      "wasm",
		"rfw_config.js": "//cfg",
	} {
		if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0o600); err != nil {
			t.Fatalf("write %s: %v", name, err)
		}
	}

	ts := httptest.NewServer(NewSSCServer(":0", root).Mux)
	defer ts.Close()
	for _, path := range []string{"/", "/app.wasm?v=abc123", "/rfw_config.js"} {
		resp, err := http.Get(ts.URL + path)
		if err != nil {
			t.Fatalf("get %s: %v", path, err)
		}
		if err := resp.Body.Close(); err != nil {
			t.Fatalf("close %s: %v", path, err)
		}
		if cache := resp.Header.Get("Cache-Control"); cache != "no-store" {
			t.Fatalf("%s Cache-Control = %q, want no-store", path, cache)
		}
	}
}
F
function

TestSSCServerWSOriginAllowlist

The /ws endpoint honours host.MuxOption guards; by default it stays open.

Parameters

ssc/server_test.go:146-167
func TestSSCServerWSOriginAllowlist(t *testing.T)

{
	root := t.TempDir()
	s := NewSSCServer(":0", root, host.WithOriginAllowlist("https://app.example.com"))
	srv := httptest.NewServer(s.Mux)
	defer srv.Close()

	req, err := http.NewRequest(http.MethodGet, srv.URL+"/ws", nil)
	if err != nil {
		t.Fatalf("request: %v", err)
	}
	req.Header.Set("Origin", "http://evil.example")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("do: %v", err)
	}
	if err := resp.Body.Close(); err != nil {
		t.Fatalf("close origin response: %v", err)
	}
	if resp.StatusCode != http.StatusForbidden {
		t.Fatalf("expected 403 for unlisted origin, got %d", resp.StatusCode)
	}
}
F
function

TestSSCServerResumesAtSessionLimit

Parameters

ssc/server_test.go:169-259
func TestSSCServerResumesAtSessionLimit(t *testing.T)

{
	type request struct{}
	type response struct {
		Count int `json:"count"`
	}
	const action = "test.ssc.resume.limit"
	if err := host.RegisterAction(action, func(_ context.Context, session *host.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)
	}

	server := httptest.NewServer(NewSSCServer(":0", t.TempDir(), host.WithSSCLimits(host.SSCLimits{
		MaxSessions:    1,
		ResumeTTL:      time.Second,
		ReplayMessages: 8,
	})).Mux)
	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
	}
	send := func(socket *websocket.Conn, message host.Inbound) {
		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)
		}
	}
	receive := func(socket *websocket.Conn) host.Outbound {
		var data []byte
		if err := websocket.Message.Receive(socket, &data); err != nil {
			t.Fatalf("receive message: %v", err)
		}
		var message host.Outbound
		if err := json.Unmarshal(data, &message); err != nil {
			t.Fatalf("decode message: %v", err)
		}
		return message
	}

	firstSocket := dial()
	send(firstSocket, host.Inbound{Action: action, ID: "first", Sequence: 1})
	first := receive(firstSocket)
	closeTestResource(t, firstSocket)

	var (
		secondSocket *websocket.Conn
		second       host.Outbound
	)
	deadline := time.Now().Add(time.Second)
	for {
		secondSocket = dial()
		send(secondSocket, host.Inbound{
			Action:      action,
			ID:          "second",
			Sequence:    2,
			Ack:         first.Sequence,
			ResumeToken: first.ResumeToken,
		})
		second = receive(secondSocket)
		if second.Session == first.Session && second.ID == "second" {
			break
		}
		closeTestResource(t, secondSocket)
		if time.Now().After(deadline) {
			t.Fatalf("session did not resume at the limit: first=%#v second=%#v", first, second)
		}
		time.Sleep(10 * time.Millisecond)
	}
	defer closeTestResource(t, secondSocket)
	payload, ok := second.Payload.(map[string]any)
	if !ok || payload["count"] != float64(2) {
		t.Fatalf("session state was not retained: %#v", second.Payload)
	}
	if session, ok := host.SessionByID(first.Session); ok {
		host.ReleaseSession(session)
	}
}