hostclient API

hostclient

package

API reference for the hostclient package.

T
type

ConnectionState

ConnectionState describes the SSC transport state.

hostclient/connection_state.go:7-7
type ConnectionState string
F
function

ConnectionStateSignal

ConnectionStateSignal returns the reactive SSC connection state.

hostclient/connection_state.go:23-25
func ConnectionStateSignal() *state.Signal[ConnectionState]

{
	return connectionState
}
S
struct

fakeElement

hostclient/hostclient_test.go:8-13
type fakeElement struct

Methods

Exists
Method

Returns

bool
func (*fakeElement) Exists() bool
{ return e.exists }
Text
Method

Returns

string
func (*fakeElement) Text() string
{ return e.text }
SetText
Method

Parameters

v string
func (*fakeElement) SetText(v string)
{ e.text = v }
Attr
Method

Parameters

name string

Returns

string
func (*fakeElement) Attr(name string) string
{
	if name == hostExpectedAttr {
		return e.expected
	}
	if e.attrStore != nil {
		return e.attrStore[name]
	}
	return ""
}
SetAttr
Method

Parameters

name string
value string
func (*fakeElement) SetAttr(name, value string)
{
	if name == hostExpectedAttr {
		e.expected = value
		return
	}
	if e.attrStore == nil {
		e.attrStore = make(map[string]string)
	}
	e.attrStore[name] = value
}

Fields

Name Type Description
text string
expected string
exists bool
attrStore map[string]string
S
struct

fakeRoot

hostclient/hostclient_test.go:42-45
type fakeRoot struct

Methods

HostVar
Method

Parameters

name string

Returns

func (*fakeRoot) HostVar(name string) hostVarElement
{
	if el, ok := r.elems[name]; ok {
		return el
	}
	return &fakeElement{}
}
SetHTML
Method

Parameters

html string
func (*fakeRoot) SetHTML(html string)
{
	r.html = html
	r.elems = make(map[string]*fakeElement)
	re := regexp.MustCompile(`<span[^>]*data-host-var="([^"]+)"[^>]*data-host-expected="([^"]*)"[^>]*>([^<]*)</span>`)
	matches := re.FindAllStringSubmatch(html, -1)
	for _, m := range matches {
		name := m[1]
		expected := m[2]
		text := m[3]
		r.elems[name] = &fakeElement{exists: true, expected: expected, text: text}
	}
}

Fields

Name Type Description
elems map[string]*fakeElement
html string
F
function

newFakeRoot

Returns

hostclient/hostclient_test.go:47-49
func newFakeRoot() *fakeRoot

{
	return &fakeRoot{elems: make(map[string]*fakeElement)}
}
F
function

TestHandleHostPayloadMismatchTriggersResync

Parameters

hostclient/hostclient_test.go:71-104
func TestHandleHostPayloadMismatchTriggersResync(t *testing.T)

{
	root := newFakeRoot()
	root.elems["greeting"] = &fakeElement{
		exists:   true,
		expected: encodeExpectation("server"),
		text:     "tampered",
	}

	payload := map[string]any{"greeting": "fresh"}
	mismatches := handleHostPayload(root, payload, nil)
	if len(mismatches) != 1 {
		t.Fatalf("expected 1 mismatch, got %d", len(mismatches))
	}
	if root.elems["greeting"].text != "tampered" {
		t.Fatalf("text was updated despite mismatch")
	}
	resync := buildResyncPayload(mismatches)
	body, ok := resync["resync"].(map[string]any)
	if !ok {
		t.Fatalf("resync payload missing body")
	}
	if body["reason"] != "host-var-mismatch" {
		t.Fatalf("unexpected reason %v", body["reason"])
	}
	vars, ok := body["vars"].([]map[string]string)
	if ok {
		if vars[0]["var"] != "greeting" {
			t.Fatalf("unexpected var name %s", vars[0]["var"])
		}
		if vars[0]["expected"] == vars[0]["actualHash"] {
			t.Fatalf("expected hashes to differ on mismatch")
		}
	}
}
F
function

TestLegacyExpectationRequiresResync

Parameters

hostclient/hostclient_test.go:106-117
func TestLegacyExpectationRequiresResync(t *testing.T)

{
	root := newFakeRoot()
	root.elems["greeting"] = &fakeElement{
		exists:   true,
		expected: "sha1:2b42fba6b3f0c7b0d352c30b63f055c1b2f507a2",
		text:     "hello",
	}

	if mismatches := handleHostPayload(root, map[string]any{"greeting": "updated"}, nil); len(mismatches) != 1 {
		t.Fatalf("legacy expectation was trusted without verification: %+v", mismatches)
	}
}
F
function

TestInitSnapshotRecoveryAndUpdate

Parameters

hostclient/hostclient_test.go:119-145
func TestInitSnapshotRecoveryAndUpdate(t *testing.T)

{
	root := newFakeRoot()
	root.elems["count"] = &fakeElement{
		exists:   true,
		expected: encodeExpectation("1"),
		text:     "0",
	}

	if mismatches := handleHostPayload(root, map[string]any{"count": "2"}, nil); len(mismatches) == 0 {
		t.Fatalf("expected mismatch when expectation diverges")
	}

	snapHTML := `<span data-host-var="count" data-host-expected="` + encodeExpectation("1") + `">1</span>`
	applyInitSnapshot(root, &initSnapshotPayload{HTML: snapHTML})

	if mismatches := handleHostPayload(root, map[string]any{"count": "3"}, nil); len(mismatches) != 0 {
		t.Fatalf("expected clean hydration after snapshot")
	}

	elem := root.HostVar("count").(*fakeElement)
	if elem.text != "3" {
		t.Fatalf("expected text to update to 3, got %s", elem.text)
	}
	if elem.expected != encodeExpectation("3") {
		t.Fatalf("expected hash to reflect new value")
	}
}
S
struct

domComponentRoot

hostclient/hydration_dom.go:11-11
type domComponentRoot struct

Methods

HostVar
Method

Parameters

name string

Returns

func (domComponentRoot) HostVar(name string) hostVarElement
{
	selector := fmt.Sprintf(`[%s="%s"]`, hostVarAttr, name)
	return domHostVarElement{r.Query(selector)}
}
SetHTML
Method

Parameters

html string
func (domComponentRoot) SetHTML(html string)
{
	r.Element.SetHTML(html)
}
F
function

newComponentRoot

Parameters

Returns

hostclient/hydration_dom.go:13-15
func newComponentRoot(el dom.Element) componentRoot

{
	return domComponentRoot{el}
}
S
struct

domHostVarElement

hostclient/hydration_dom.go:26-26
type domHostVarElement struct

Methods

Exists
Method

Returns

bool
func (domHostVarElement) Exists() bool
{ return e.Truthy() }
Text
Method

Returns

string
func (domHostVarElement) Text() string
{ return e.Element.Text() }
SetText
Method

Parameters

value string
func (domHostVarElement) SetText(value string)
{ e.Element.SetText(value) }
Attr
Method

Parameters

name string

Returns

string
func (domHostVarElement) Attr(name string) string
{ return e.Element.Attr(name) }
SetAttr
Method

Parameters

name string
value string
func (domHostVarElement) SetAttr(name, value string)
{ e.Element.SetAttr(name, value) }
I
interface

hostVarElement

hostclient/hydration_shared.go:16-22
type hostVarElement interface

Methods

Exists
Method

Returns

bool
func Exists(...)
Text
Method

Returns

string
func Text(...)
SetText
Method

Parameters

string
func SetText(...)
Attr
Method

Parameters

string

Returns

string
func Attr(...)
SetAttr
Method

Parameters

string
string
func SetAttr(...)
I
interface

componentRoot

hostclient/hydration_shared.go:24-27
type componentRoot interface

Methods

HostVar
Method

Parameters

string

Returns

func HostVar(...)
SetHTML
Method

Parameters

string
func SetHTML(...)
S
struct

hydrationMismatch

hostclient/hydration_shared.go:29-35
type hydrationMismatch struct

Fields

Name Type Description
VarName string
Expected string
Actual string
ActualHash string
ExpectedAlg string
S
struct

initSnapshotPayload

hostclient/hydration_shared.go:37-40
type initSnapshotPayload struct

Fields

Name Type Description
HTML string
Vars []string
F
function

encodeExpectation

Parameters

value
string

Returns

string
hostclient/hydration_shared.go:42-45
func encodeExpectation(value string) string

{
	sum := sha256.Sum256([]byte(value))
	return fmt.Sprintf("%s:%s", expectationHashAlg, hex.EncodeToString(sum[:]))
}
F
function

expectationMatches

Parameters

expectedAttr
string
actual
string

Returns

bool
string
string
hostclient/hydration_shared.go:47-56
func expectationMatches(expectedAttr, actual string) (bool, string, string)

{
	actualHash := encodeExpectation(actual)
	if expectedAttr == "" {
		return true, expectationHashAlg, actualHash
	}
	if strings.HasPrefix(expectedAttr, expectationHashAlg+":") {
		return expectedAttr == actualHash, expectationHashAlg, actualHash
	}
	return expectedAttr == actual, "raw", actualHash
}
F
function

updateHostVar

Parameters

name
string
value
string
hostclient/hydration_shared.go:58-78
func updateHostVar(root componentRoot, name, value string) *hydrationMismatch

{
	node := root.HostVar(name)
	if !node.Exists() {
		return nil
	}
	expectedAttr := node.Attr(hostExpectedAttr)
	actualText := node.Text()
	matches, alg, actualHash := expectationMatches(expectedAttr, actualText)
	if !matches {
		return &hydrationMismatch{
			VarName:     name,
			Expected:    expectedAttr,
			Actual:      actualText,
			ActualHash:  actualHash,
			ExpectedAlg: alg,
		}
	}
	node.SetText(value)
	node.SetAttr(hostExpectedAttr, encodeExpectation(value))
	return nil
}
F
function

handleHostPayload

Parameters

payload
map[string]any
updateSignal
func(name string, raw any)

Returns

hostclient/hydration_shared.go:80-95
func handleHostPayload(root componentRoot, payload map[string]any, updateSignal func(name string, raw any)) []hydrationMismatch

{
	mismatches := make([]hydrationMismatch, 0)
	for key, raw := range payload {
		if key == "initSnapshot" || strings.HasPrefix(key, "_") {
			continue
		}
		mismatch := updateHostVar(root, key, fmt.Sprintf("%v", raw))
		if mismatch != nil {
			mismatches = append(mismatches, *mismatch)
		}
		if updateSignal != nil {
			updateSignal(key, raw)
		}
	}
	return mismatches
}
F
function

applyInitSnapshot

Parameters

hostclient/hydration_shared.go:97-102
func applyInitSnapshot(root componentRoot, payload *initSnapshotPayload)

{
	if payload == nil {
		return
	}
	root.SetHTML(payload.HTML)
}
F
function

buildResyncPayload

Parameters

mismatches

Returns

map[string]any
hostclient/hydration_shared.go:104-121
func buildResyncPayload(mismatches []hydrationMismatch) map[string]any

{
	entries := make([]map[string]string, 0, len(mismatches))
	for _, m := range mismatches {
		entries = append(entries, map[string]string{
			"var":         m.VarName,
			"expected":    m.Expected,
			"expectedAlg": m.ExpectedAlg,
			"actual":      m.Actual,
			"actualHash":  m.ActualHash,
		})
	}
	return map[string]any{
		"resync": map[string]any{
			"reason": "host-var-mismatch",
			"vars":   entries,
		},
	}
}
S
struct

componentBinding

hostclient/runtime_foundation.go:25-28
type componentBinding struct

Fields

Name Type Description
id string
vars []string
S
struct

message

hostclient/runtime_foundation.go:65-71
type message struct

Fields

Name Type Description
name string
action string
id string
payload any
sequence uint64
S
struct

wireMessage

hostclient/runtime_foundation.go:73-81
type wireMessage struct

Fields

Name Type Description
Component string json:"component,omitempty"
Action string json:"action,omitempty"
ID string json:"id,omitempty"
Payload any json:"payload,omitempty"
Sequence uint64 json:"sequence"
Ack uint64 json:"ack,omitempty"
ResumeToken string json:"resumeToken,omitempty"
T
type

messageWriter

hostclient/runtime_foundation.go:83-83
type messageWriter func(context.Context, *websocket.Conn, wireMessage) error
S
struct

actionReply

hostclient/runtime_foundation.go:85-88
type actionReply struct

Fields

Name Type Description
payload any
err *ActionError
F
function

decodeInitSnapshotPayload

Parameters

raw
any
hostclient/runtime_foundation.go:90-114
func decodeInitSnapshotPayload(raw any) *initSnapshotPayload

{
	if raw == nil {
		return nil
	}
	m, ok := raw.(map[string]any)
	if !ok {
		return nil
	}
	html, _ := m["html"].(string)
	if html == "" {
		return nil
	}
	var vars []string
	if list, ok := m["vars"].([]any); ok {
		vars = make([]string, 0, len(list))
		for _, item := range list {
			if s, ok := item.(string); ok {
				vars = append(vars, s)
			}
		}
	} else if list, ok := m["vars"].([]string); ok {
		vars = append(vars, list...)
	}
	return &initSnapshotPayload{HTML: html, Vars: vars}
}
S
struct

ActionError

ActionError is a machine-readable error returned by a typed host action.

hostclient/runtime_foundation.go:117-121
type ActionError struct

Methods

Error
Method

Returns

string
func (*ActionError) Error() string
{
	if e == nil {
		return ""
	}
	return e.Code + ": " + e.Message
}

Fields

Name Type Description
Code string json:"code"
Message string json:"message"
Fields map[string]string json:"fields,omitempty"
F
function

init

hostclient/runtime_foundation.go:130-142
func init()

{
	cb = fnres.NewCircuitBreaker(5, 30*time.Second)
	cb.OnStateChange(func(from, to fnres.State) {
		if debug {
			log.Printf("hostclient: circuit %v -> %v", from, to)
		}
	})
	hydrateCB = fnres.NewCircuitBreaker(3, 15*time.Second)
	sendCache = fncaching.NewInMemory[string](
		fncaching.WithMaxEntries[string](256),
		fncaching.WithTTL[string](5*time.Second),
	)
}
F
function

connect

hostclient/runtime_foundation.go:144-153
func connect()

{
	once.Do(func() {
		go func() {
			for {
				js.Guard("host connection loop", connectionLoop)
				time.Sleep(time.Second)
			}
		}()
	})
}
F
function

hostWSURL

hostWSURL builds the WebSocket URL the client uses to reach its host.
The endpoint is resolved in order of precedence: a full URL in
window.RFW_HOST_URL (ws, wss, http, https, or a bare host[:port] with an
optional path), the legacy host[:port] in window.RFW_HOST, or the page
origin. The path defaults to /ws when the endpoint carries none.

Returns

string
hostclient/runtime_foundation.go:160-171
func hostWSURL() string

{
	if u := js.Get("RFW_HOST_URL"); u.Truthy() {
		if s := normalizeWSURL(u.String()); s != "" {
			return s
		}
	}
	host := js.Location().Get("host").String()
	if h := js.Get("RFW_HOST"); h.Truthy() {
		host = h.String()
	}
	return normalizeWSURL(host)
}
F
function

normalizeWSURL

normalizeWSURL turns a configured endpoint into a WebSocket URL: http and
https map to ws and wss, a bare host takes the page scheme, and /ws is
appended when the endpoint carries no path.

Parameters

raw
string

Returns

string
hostclient/runtime_foundation.go:176-198
func normalizeWSURL(raw string) string

{
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return ""
	}
	switch {
	case strings.HasPrefix(raw, "ws://"), strings.HasPrefix(raw, "wss://"):
	case strings.HasPrefix(raw, "http://"):
		raw = "ws://" + strings.TrimPrefix(raw, "http://")
	case strings.HasPrefix(raw, "https://"):
		raw = "wss://" + strings.TrimPrefix(raw, "https://")
	default:
		scheme := "wss"
		if js.Location().Get("protocol").String() == "http:" {
			scheme = "ws"
		}
		raw = scheme + "://" + raw
	}
	if rest := raw[strings.Index(raw, "://")+3:]; !strings.Contains(rest, "/") {
		raw = strings.TrimRight(raw, "/") + "/ws"
	}
	return raw
}
F
function

connectionLoop

hostclient/runtime_foundation.go:200-304
func connectionLoop()

{
	for {
		url := hostWSURL()
		connectionState.Set(ConnectionConnecting)

		err := fnres.Retry(context.Background(), func() error {
			return cb.Execute(func() error {
				if debug {
					log.Printf("hostclient: dialing %s", url)
				}
				ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
				defer cancel()
				c, _, derr := websocket.Dial(ctx, url, nil)
				if derr != nil {
					return derr
				}
				c.SetReadLimit(maxInboundMessageBytes)

				sendMu.Lock()
				mu.Lock()
				conn = c
				pend := pending
				pending = nil
				mu.Unlock()

				if debug {
					log.Printf("hostclient: connected")
				}
				connectionState.Set(ConnectionConnected)

				mu.RLock()
				names := make([]string, 0, len(bindings)+len(handlers))
				for name := range bindings {
					names = append(names, name)
				}
				for name := range handlers {
					if _, bound := bindings[name]; !bound {
						names = append(names, name)
					}
				}
				mu.RUnlock()
				deliveryMu.Lock()
				unacknowledged := make([]message, 0, len(outbox))
				for sequence := uint64(1); sequence <= nextOutbound; sequence++ {
					if msg, ok := outbox[sequence]; ok {
						unacknowledged = append(unacknowledged, msg)
					}
				}
				deliveryMu.Unlock()
				initialized := make(map[string]struct{})
				for _, msg := range unacknowledged {
					sendMessageUnlocked(c, msg)
					if name, ok := initMessageName(msg); ok {
						initialized[name] = struct{}{}
					}
				}
				for _, msg := range pend {
					sendMessageUnlocked(c, msg)
					if name, ok := initMessageName(msg); ok {
						initialized[name] = struct{}{}
					}
				}
				for _, name := range names {
					if _, sent := initialized[name]; sent {
						continue
					}
					sendMessageUnlocked(c, message{name: name, payload: map[string]any{"init": true}})
				}
				sendMu.Unlock()

				ctx2, cancel2 := context.WithCancel(context.Background())
				defer cancel2()
				errCh := make(chan error, 2)
				go func() { errCh <- guardedLoop("host read loop", func() error { return readLoop(ctx2, c) }) }()
				go func() { errCh <- guardedLoop("host ping loop", func() error { return pingLoop(ctx2, c) }) }()
				loopErr := <-errCh
				cancel2()
				closeErr := c.Close(websocket.StatusInternalError, "connection closed")

				mu.Lock()
				conn = nil
				mu.Unlock()
				connectionState.Set(ConnectionDisconnected)
				if loopErr != nil {
					return loopErr
				}
				return closeErr
			})
		},
			fnres.WithAttempts(5),
			fnres.WithDelay(time.Second, 30*time.Second),
			fnres.WithFactor(2),
			fnres.WithJitter(0.1),
			fnres.WithRetryIf(func(err error) bool { return err != nil }),
		)

		if err != nil && debug {
			log.Printf("hostclient: connection attempt failed: %v", err)
		}
		connectionState.Set(ConnectionDisconnected)

		// Back off before reconnecting to avoid tight loops on persistent failures.
		time.Sleep(time.Second)
	}
}
F
function

guardedLoop

Parameters

context
string
fn
func() error

Returns

error
hostclient/runtime_foundation.go:306-312
func guardedLoop(context string, fn func() error) error

{
	var err error
	if !js.Guard(context, func() { err = fn() }) {
		return fmt.Errorf("%s panicked", context)
	}
	return err
}
F
function

pingLoop

Parameters

Returns

error
hostclient/runtime_foundation.go:314-330
func pingLoop(ctx context.Context, c *websocket.Conn) error

{
	ticker := time.NewTicker(30 * time.Second)
	defer ticker.Stop()
	for {
		select {
		case <-ticker.C:
			pctx, cancel := context.WithTimeout(ctx, 5*time.Second)
			err := c.Ping(pctx)
			cancel()
			if err != nil {
				return err
			}
		case <-ctx.Done():
			return ctx.Err()
		}
	}
}
F
function

readLoop

Parameters

Returns

error
hostclient/runtime_foundation.go:332-411
func readLoop(ctx context.Context, c *websocket.Conn) error

{
	for {
		var msg struct {
			Component   string       `json:"component"`
			Action      string       `json:"action"`
			Control     string       `json:"control"`
			ID          string       `json:"id"`
			Payload     any          `json:"payload"`
			Error       *ActionError `json:"error"`
			Session     string       `json:"session"`
			Sequence    uint64       `json:"sequence"`
			Ack         uint64       `json:"ack"`
			ResumeToken string       `json:"resumeToken"`
		}
		if err := wsjson.Read(ctx, c, &msg); err != nil {
			return err
		}
		if debug {
			log.Printf("hostclient: recv %s %v", msg.Component, msg.Payload)
		}
		prepareInboundDelivery(msg.Session, msg.Control)
		deliveryMu.Lock()
		for sequence := range outbox {
			if sequence <= msg.Ack {
				delete(outbox, sequence)
			}
		}
		if msg.Sequence != 0 {
			if msg.Sequence <= lastInbound {
				deliveryMu.Unlock()
				continue
			}
			if lastInbound != 0 && msg.Sequence != lastInbound+1 {
				deliveryMu.Unlock()
				connectionState.Set(ConnectionDesynced)
				return errors.New("hostclient: server message sequence gap")
			}
			lastInbound = msg.Sequence
		}
		if msg.ResumeToken != "" {
			resumeToken = msg.ResumeToken
		}
		deliveryMu.Unlock()
		if msg.ID != "" {
			callMu.Lock()
			replyChannel := pendingCalls[msg.ID]
			if replyChannel != nil {
				delete(pendingCalls, msg.ID)
			}
			callMu.Unlock()
			if replyChannel != nil {
				replyChannel <- actionReply{payload: msg.Payload, err: msg.Error}
				continue
			}
		}
		if msg.Control != "" {
			continue
		}
		payload, _ := msg.Payload.(map[string]any)
		if payload == nil {
			payload = make(map[string]any)
		}
		mu.RLock()
		h, hasHandler := handlers[msg.Component]
		b, hasBinding := bindings[msg.Component]
		mu.RUnlock()
		if hasHandler {
			if msg.Session != "" {
				payload["_session"] = msg.Session
			}
			js.Guard("host handler: "+msg.Component, func() { h(payload) })
			continue
		}
		if hasBinding {
			js.Guard("host binding: "+msg.Component, func() {
				applyHostBinding(msg.Component, payload, b)
			})
		}
	}
}
F
function

applyHostBinding

Parameters

component
string
payload
map[string]any
hostclient/runtime_foundation.go:413-454
func applyHostBinding(component string, payload map[string]any, binding componentBinding)

{
	rootEl := dom.ComponentRoot(binding.id)
	if !rootEl.Truthy() {
		return
	}
	root := newComponentRoot(rootEl)
	if snap := decodeInitSnapshotPayload(payload["initSnapshot"]); snap != nil {
		applyInitSnapshot(root, snap)
		if len(snap.Vars) > 0 {
			binding.vars = append([]string(nil), snap.Vars...)
			mu.Lock()
			bindings[component] = binding
			mu.Unlock()
		}
		return
	}

	mismatches := handleHostPayload(root, payload, func(name string, raw any) {
		signals := dom.SnapshotComponentSignals(binding.id)
		if signals == nil {
			return
		}
		if signal, ok := signals[name]; ok {
			if setter, ok := signal.(interface{ SetFromHost(any) }); ok {
				setter.SetFromHost(raw)
			}
		}
	})
	if len(mismatches) == 0 {
		return
	}
	for _, mismatch := range mismatches {
		log.Printf("hostclient: hydration mismatch component=%s var=%s expected=%s actualHash=%s actual=%q", component, mismatch.VarName, mismatch.Expected, mismatch.ActualHash, mismatch.Actual)
	}
	resyncErr := hydrateCB.Execute(func() error {
		Send(component, buildResyncPayload(mismatches))
		return nil
	})
	if resyncErr != nil {
		log.Printf("hostclient: hydration circuit open, skipping resync for %s", component)
	}
}
F
function

prepareInboundDelivery

Parameters

remoteSession
string
control
string
hostclient/runtime_foundation.go:456-470
func prepareInboundDelivery(remoteSession, control string)

{
	sessionMu.Lock()
	previousSession := sessionID
	if remoteSession != "" {
		sessionID = remoteSession
	}
	sessionMu.Unlock()
	if control != "resume_rejected" && (remoteSession == "" || previousSession == "" || remoteSession == previousSession) {
		return
	}
	deliveryMu.Lock()
	lastInbound = 0
	resumeToken = ""
	deliveryMu.Unlock()
}
F
function

RegisterComponent

RegisterComponent binds a client component to a host component name.

Parameters

id
string
name
string
vars
[]string
hostclient/runtime_foundation.go:473-485
func RegisterComponent(id, name string, vars []string)

{
	mu.Lock()
	bindings[name] = componentBinding{id: id, vars: vars}
	current := conn
	if current == nil {
		pending = append(pending, message{name: name, payload: map[string]any{"init": true}})
	}
	mu.Unlock()
	connect()
	if current != nil {
		sendMessage(current, message{name: name, payload: map[string]any{"init": true}})
	}
}
F
function

EnableSendDedup

EnableSendDedup turns on payload-based deduplication for the named channel:
identical payloads sent within a 5 second window are dropped. Dedup is off
by default because repeated identical messages are usually intentional user
actions (e.g. clicking +1 twice); opt in only for channels where duplicate
suppression is the desired semantic.

Parameters

name
string
hostclient/runtime_foundation.go:492-496
func EnableSendDedup(name string)

{
	mu.Lock()
	dedup[name] = struct{}{}
	mu.Unlock()
}
F
function

dedupEnabled

Parameters

name
string

Returns

bool
hostclient/runtime_foundation.go:498-503
func dedupEnabled(name string) bool

{
	mu.RLock()
	_, ok := dedup[name]
	mu.RUnlock()
	return ok
}
F
function

Send

Send queues or transmits a host component message.

Parameters

name
string
payload
any
hostclient/runtime_foundation.go:506-531
func Send(name string, payload any)

{
	connect()
	if dedupEnabled(name) {
		key := fmt.Sprintf("%s|%v", name, payload)
		if _, ok, _ := sendCache.Get(context.Background(), key); ok {
			return
		}
		if err := sendCache.Set(context.Background(), key, "sent", 5*time.Second); err != nil {
			log.Printf("hostclient: dedup cache set failed: %v", err)
		}
	}

	mu.RLock()
	c := conn
	mu.RUnlock()
	if c == nil {
		mu.Lock()
		pending = append(pending, message{name: name, payload: payload})
		mu.Unlock()
		return
	}
	if debug {
		log.Printf("hostclient: send %s %v", name, payload)
	}
	sendMessage(c, message{name: name, payload: payload})
}
F
function

RegisterHandler

RegisterHandler registers a handler for host messages and returns an
idempotent unsubscribe function. Unsubscribing removes the handler from
reconnect hydration and tells the active host session to stop broadcasts for
the component. A stale unsubscribe closure never removes a newer handler
registered under the same name.

Parameters

name
string
h
func(map[string]any)

Returns

func()
hostclient/runtime_foundation.go:538-583
func RegisterHandler(name string, h func(map[string]any)) func()

{
	token := handlerSequence.Add(1)
	mu.Lock()
	handlers[name] = h
	handlerTokens[name] = token
	current := conn
	if current == nil {
		pending = append(pending, message{name: name, payload: map[string]any{"init": true}})
	}
	mu.Unlock()
	connect()
	if current != nil {
		sendMessage(current, message{name: name, payload: map[string]any{"init": true}})
	}
	var once sync.Once
	return func() {
		once.Do(func() {
			mu.Lock()
			if handlerTokens[name] != token {
				mu.Unlock()
				return
			}
			delete(handlers, name)
			delete(handlerTokens, name)
			filtered := pending[:0]
			for _, queued := range pending {
				if queued.name == name && isInitPayload(queued.payload) {
					continue
				}
				filtered = append(filtered, queued)
			}
			pending = filtered
			current := conn
			mu.Unlock()

			unsubscribe := message{name: name, payload: map[string]any{"unsubscribe": true}}
			if current != nil {
				sendMessage(current, unsubscribe)
				return
			}
			mu.Lock()
			pending = append(pending, unsubscribe)
			mu.Unlock()
		})
	}
}
F
function

isInitPayload

Parameters

payload
any

Returns

bool
hostclient/runtime_foundation.go:585-588
func isInitPayload(payload any) bool

{
	values, ok := payload.(map[string]any)
	return ok && values["init"] == true
}
F
function

SessionID

SessionID returns the current SSC session ID.

Returns

string
hostclient/runtime_foundation.go:591-595
func SessionID() string

{
	sessionMu.RLock()
	defer sessionMu.RUnlock()
	return sessionID
}
F
function

sendMessage

Parameters

hostclient/runtime_foundation.go:597-599
func sendMessage(c *websocket.Conn, msg message)

{
	sendMessageWithWriter(c, msg, writeMessage)
}
F
function

sendMessageWithWriter

Parameters

hostclient/runtime_foundation.go:601-605
func sendMessageWithWriter(c *websocket.Conn, msg message, writer messageWriter)

{
	sendMu.Lock()
	defer sendMu.Unlock()
	sendMessageUnlockedWithWriter(c, msg, writer)
}
F
function

sendMessageUnlocked

Parameters

hostclient/runtime_foundation.go:607-609
func sendMessageUnlocked(c *websocket.Conn, msg message)

{
	sendMessageUnlockedWithWriter(c, msg, writeMessage)
}
F
function

sendMessageUnlockedWithWriter

Parameters

hostclient/runtime_foundation.go:611-632
func sendMessageUnlockedWithWriter(c *websocket.Conn, msg message, writer messageWriter)

{
	deliveryMu.Lock()
	if msg.sequence == 0 {
		nextOutbound++
		msg.sequence = nextOutbound
		outbox[msg.sequence] = msg
	}
	token := resumeToken
	ack := lastInbound
	deliveryMu.Unlock()
	outbound := wireMessage{
		Component:   msg.name,
		Action:      msg.action,
		ID:          msg.id,
		Payload:     msg.payload,
		Sequence:    msg.sequence,
		Ack:         ack,
		ResumeToken: token,
	}
	ctx := context.Background()
	_ = writer(ctx, c, outbound)
}
F
function

writeMessage

Parameters

Returns

error
hostclient/runtime_foundation.go:634-636
func writeMessage(ctx context.Context, c *websocket.Conn, message wireMessage) error

{
	return wsjson.Write(ctx, c, message)
}
F
function

initMessageName

Parameters

msg

Returns

string
bool
hostclient/runtime_foundation.go:638-647
func initMessageName(msg message) (string, bool)

{
	if msg.name == "" || msg.action != "" {
		return "", false
	}
	payload, ok := msg.payload.(map[string]any)
	if !ok || payload["init"] != true {
		return "", false
	}
	return msg.name, true
}
F
function

Call

Call invokes a typed SSC action and waits for its correlated response.

Parameters

action
string
request

Returns

error
hostclient/runtime_foundation.go:650-696
func Call[Request, Response any](ctx context.Context, action string, request Request) (Response, error)

{
	var zero Response
	if ctx == nil {
		ctx = context.Background()
	}
	if action == "" {
		return zero, errors.New("hostclient: empty action name")
	}
	connect()
	id := fmt.Sprintf("call-%d", callSequence.Add(1))
	replyChannel := make(chan actionReply, 1)
	callMu.Lock()
	pendingCalls[id] = replyChannel
	callMu.Unlock()

	msg := message{action: action, id: id, payload: request}
	mu.RLock()
	current := conn
	mu.RUnlock()
	if current == nil {
		mu.Lock()
		pending = append(pending, msg)
		mu.Unlock()
	} else {
		sendMessage(current, msg)
	}

	select {
	case reply := <-replyChannel:
		if reply.err != nil {
			return zero, reply.err
		}
		data, err := json.Marshal(reply.payload)
		if err != nil {
			return zero, fmt.Errorf("hostclient: encode action response: %w", err)
		}
		if err := json.Unmarshal(data, &zero); err != nil {
			return zero, fmt.Errorf("hostclient: decode action response: %w", err)
		}
		return zero, nil
	case <-ctx.Done():
		callMu.Lock()
		delete(pendingCalls, id)
		callMu.Unlock()
		return zero, ctx.Err()
	}
}
S
struct

FormResponse

FormResponse is the typed result returned by host.RegisterForm.

hostclient/runtime_foundation.go:699-703
type FormResponse struct

Fields

Name Type Description
Data Response json:"data,omitempty"
Fields map[string]string json:"fields,omitempty"
Valid bool json:"valid"
F
function

SubmitForm

SubmitForm invokes a typed SSC form action.

Parameters

action
string
values
Values

Returns

FormResponse[Response]
error
hostclient/runtime_foundation.go:706-708
func SubmitForm[Values, Response any](ctx context.Context, action string, values Values) (FormResponse[Response], error)

{
	return Call[Values, FormResponse[Response]](ctx, action, values)
}
F
function

EnableDebug

EnableDebug enables host client debug logging.

hostclient/runtime_foundation.go:711-711
func EnableDebug()

{ debug = true }
F
function

TestGuardedLoopConvertsPanicAndNextLoopRuns

Parameters

hostclient/runtime_wasm_test.go:14-29
func TestGuardedLoopConvertsPanicAndNextLoopRuns(t *testing.T)

{
	previous := js.OnRuntimePanic
	defer func() { js.OnRuntimePanic = previous }()
	recovered := 0
	js.OnRuntimePanic = func(any, string, []byte) { recovered++ }

	if err := guardedLoop("read", func() error { panic("bad push") }); err == nil {
		t.Fatal("panicking loop returned nil")
	}
	if err := guardedLoop("read", func() error { return nil }); err != nil {
		t.Fatalf("next loop returned %v", err)
	}
	if recovered != 1 {
		t.Fatalf("recovered loop panics = %d, want 1", recovered)
	}
}
F
function

TestInboundMessageLimitSupportsHydrationSnapshots

Parameters

hostclient/runtime_wasm_test.go:31-38
func TestInboundMessageLimitSupportsHydrationSnapshots(t *testing.T)

{
	if maxInboundMessageBytes < 1<<20 {
		t.Fatalf("inbound message limit = %d, want at least 1 MiB", maxInboundMessageBytes)
	}
	if maxInboundMessageBytes > 16<<20 {
		t.Fatalf("inbound message limit = %d, want a bounded ceiling", maxInboundMessageBytes)
	}
}
F
function

pendingCount

Returns

int
hostclient/runtime_wasm_test.go:40-44
func pendingCount() int

{
	mu.RLock()
	defer mu.RUnlock()
	return len(pending)
}
F
function

TestRegisterHandlerUnsubscribeQueuesWireUnsubscribe

Parameters

hostclient/runtime_wasm_test.go:46-77
func TestRegisterHandlerUnsubscribeQueuesWireUnsubscribe(t *testing.T)

{
	name := "scoped-handler"
	before := pendingCount()
	unsubscribe := RegisterHandler(name, func(map[string]any) {})
	if got := pendingCount() - before; got != 1 {
		t.Fatalf("queued subscribe messages = %d, want 1", got)
	}
	unsubscribe()

	mu.RLock()
	_, stillRegistered := handlers[name]
	queued := append([]message(nil), pending...)
	mu.RUnlock()
	if stillRegistered {
		t.Fatal("handler remained registered after unsubscribe")
	}
	count := 0
	for _, item := range queued {
		if item.name == name {
			values, _ := item.payload.(map[string]any)
			if values["unsubscribe"] == true {
				count++
			}
			if values["init"] == true {
				t.Fatal("stale init remained queued after unsubscribe")
			}
		}
	}
	if count != 1 {
		t.Fatalf("queued unsubscribe messages = %d, want 1", count)
	}
}
F
function

TestStaleUnsubscribeDoesNotRemoveReplacementHandler

Parameters

hostclient/runtime_wasm_test.go:79-91
func TestStaleUnsubscribeDoesNotRemoveReplacementHandler(t *testing.T)

{
	name := "replacement-handler"
	first := RegisterHandler(name, func(map[string]any) {})
	second := RegisterHandler(name, func(map[string]any) {})
	first()
	mu.RLock()
	_, registered := handlers[name]
	mu.RUnlock()
	if !registered {
		t.Fatal("stale unsubscribe removed replacement handler")
	}
	second()
}
F
function

TestSendRepeatedMessagesNotDeduped

Repeated identical messages must go through by default: two identical user
actions within the dedup window (e.g. clicking +1 twice) are intentional.

Parameters

hostclient/runtime_wasm_test.go:95-102
func TestSendRepeatedMessagesNotDeduped(t *testing.T)

{
	before := pendingCount()
	Send("CounterHost", map[string]any{"cmd": "increment"})
	Send("CounterHost", map[string]any{"cmd": "increment"})
	if got := pendingCount() - before; got != 2 {
		t.Fatalf("expected 2 queued messages, got %d", got)
	}
}
F
function

TestSendDedupOptIn

Dedup is opt-in per channel: after EnableSendDedup identical payloads within
the TTL window are dropped.

Parameters

hostclient/runtime_wasm_test.go:106-114
func TestSendDedupOptIn(t *testing.T)

{
	EnableSendDedup("DedupHost")
	before := pendingCount()
	Send("DedupHost", map[string]any{"cmd": "refresh"})
	Send("DedupHost", map[string]any{"cmd": "refresh"})
	if got := pendingCount() - before; got != 1 {
		t.Fatalf("expected 1 queued message after dedup, got %d", got)
	}
}
F
function

TestSendMessageSerializesSequenceAndWrite

Parameters

hostclient/runtime_wasm_test.go:116-172
func TestSendMessageSerializesSequenceAndWrite(t *testing.T)

{
	deliveryMu.Lock()
	savedNext := nextOutbound
	savedOutbox := outbox
	nextOutbound = 0
	outbox = map[uint64]message{}
	deliveryMu.Unlock()
	defer func() {
		deliveryMu.Lock()
		nextOutbound = savedNext
		outbox = savedOutbox
		deliveryMu.Unlock()
	}()

	firstEntered := make(chan struct{}, 1)
	firstRelease := make(chan struct{})
	secondEntered := make(chan struct{}, 1)
	firstDone := make(chan struct{})
	secondDone := make(chan struct{})
	firstWriter := func(_ context.Context, _ *websocket.Conn, message wireMessage) error {
		firstEntered <- struct{}{}
		<-firstRelease
		if message.Sequence != 1 {
			t.Errorf("first sequence = %d, want 1", message.Sequence)
		}
		return nil
	}
	secondWriter := func(_ context.Context, _ *websocket.Conn, message wireMessage) error {
		secondEntered <- struct{}{}
		if message.Sequence != 2 {
			t.Errorf("second sequence = %d, want 2", message.Sequence)
		}
		return nil
	}

	go func() {
		sendMessageWithWriter(nil, message{name: "first"}, firstWriter)
		close(firstDone)
	}()
	<-firstEntered
	go func() {
		sendMessageWithWriter(nil, message{name: "second"}, secondWriter)
		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
}
F
function

TestPrepareInboundDeliveryResetsNewSessionState

Parameters

hostclient/runtime_wasm_test.go:174-207
func TestPrepareInboundDeliveryResetsNewSessionState(t *testing.T)

{
	sessionMu.Lock()
	savedSession := sessionID
	sessionID = "old-session"
	sessionMu.Unlock()
	deliveryMu.Lock()
	savedInbound := lastInbound
	savedToken := resumeToken
	lastInbound = 9
	resumeToken = "old-token"
	deliveryMu.Unlock()
	defer func() {
		sessionMu.Lock()
		sessionID = savedSession
		sessionMu.Unlock()
		deliveryMu.Lock()
		lastInbound = savedInbound
		resumeToken = savedToken
		deliveryMu.Unlock()
	}()

	prepareInboundDelivery("new-session", "")

	sessionMu.RLock()
	currentSession := sessionID
	sessionMu.RUnlock()
	deliveryMu.Lock()
	currentInbound := lastInbound
	currentToken := resumeToken
	deliveryMu.Unlock()
	if currentSession != "new-session" || currentInbound != 0 || currentToken != "" {
		t.Fatalf("delivery state was not reset: session=%q inbound=%d token=%q", currentSession, currentInbound, currentToken)
	}
}
F
function

TestPrepareInboundDeliveryResetsRejectedResume

Parameters

hostclient/runtime_wasm_test.go:209-239
func TestPrepareInboundDeliveryResetsRejectedResume(t *testing.T)

{
	sessionMu.Lock()
	savedSession := sessionID
	sessionID = "current-session"
	sessionMu.Unlock()
	deliveryMu.Lock()
	savedInbound := lastInbound
	savedToken := resumeToken
	lastInbound = 9
	resumeToken = "old-token"
	deliveryMu.Unlock()
	defer func() {
		sessionMu.Lock()
		sessionID = savedSession
		sessionMu.Unlock()
		deliveryMu.Lock()
		lastInbound = savedInbound
		resumeToken = savedToken
		deliveryMu.Unlock()
	}()

	prepareInboundDelivery("current-session", "resume_rejected")

	deliveryMu.Lock()
	currentInbound := lastInbound
	currentToken := resumeToken
	deliveryMu.Unlock()
	if currentInbound != 0 || currentToken != "" {
		t.Fatalf("rejected resume state was not reset: inbound=%d token=%q", currentInbound, currentToken)
	}
}