fakeElement struct

Fields:

  • text (string)
  • expected (string)
  • exists (bool)
  • attrStore (map[string]string)

Methods:

Exists


Returns:
  • bool

Show/Hide Method Body
{ return e.exists }

Text


Returns:
  • string

Show/Hide Method Body
{ return e.text }

SetText


Parameters:
  • v string

Show/Hide Method Body
{ e.text = v }

Attr


Parameters:
  • name string

Returns:
  • string

Show/Hide Method Body
{
	if name == hostExpectedAttr {
		return e.expected
	}
	if e.attrStore != nil {
		return e.attrStore[name]
	}
	return ""
}

SetAttr


Parameters:
  • name string
  • value string

Show/Hide Method Body
{
	if name == hostExpectedAttr {
		e.expected = value
		return
	}
	if e.attrStore == nil {
		e.attrStore = make(map[string]string)
	}
	e.attrStore[name] = value
}

fakeRoot struct

Fields:

  • elems (map[string]*fakeElement)
  • html (string)

Methods:

HostVar


Parameters:
  • name string

Returns:
  • hostVarElement

References:


Show/Hide Method Body
{
	if el, ok := r.elems[name]; ok {
		return el
	}
	return &fakeElement{}
}

SetHTML


Parameters:
  • html string

Show/Hide Method Body
{
	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}
	}
}

newFakeRoot function

Returns:

  • *fakeRoot
Show/Hide Function Body
{
	return &fakeRoot{elems: make(map[string]*fakeElement)}
}

TestHandleHostPayloadMismatchTriggersResync function

Parameters:

  • t *testing.T
Show/Hide Function Body
{
	root := newFakeRoot()
	root.elems["greeting"] = &fakeElement{
		exists:   true,
		expected: encodeExpectation("server"),
		text:     "tampered",
	}

	payload := map[string]any{"greeting": "fresh"}
	mismatches := handleHostPayload(root, payload)
	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")
		}
	}
}

TestInitSnapshotRecoveryAndUpdate function

Parameters:

  • t *testing.T
Show/Hide Function Body
{
	root := newFakeRoot()
	root.elems["count"] = &fakeElement{
		exists:   true,
		expected: encodeExpectation("1"),
		text:     "0",
	}

	if mismatches := handleHostPayload(root, map[string]any{"count": "2"}); 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"}); 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")
	}
}

domComponentRoot struct

Methods:

HostVar


Parameters:
  • name string

Returns:
  • hostVarElement

References:


Show/Hide Method Body
{
	selector := fmt.Sprintf(`[%s="%s"]`, hostVarAttr, name)
	return domHostVarElement{r.Element.Query(selector)}
}

SetHTML


Parameters:
  • html string

Show/Hide Method Body
{
	r.Element.SetHTML(html)
}

newComponentRoot function

Parameters:

  • el dom.Element

Returns:

  • componentRoot

References:

Show/Hide Function Body
{
	return domComponentRoot{el}
}

domHostVarElement struct

Methods:

Exists


Returns:
  • bool

Show/Hide Method Body
{ return e.Value.Truthy() }

Text


Returns:
  • string

Show/Hide Method Body
{ return e.Element.Text() }

SetText


Parameters:
  • value string

Show/Hide Method Body
{ e.Element.SetText(value) }

Attr


Parameters:
  • name string

Returns:
  • string

Show/Hide Method Body
{ return e.Element.Attr(name) }

SetAttr


Parameters:
  • name string
  • value string

Show/Hide Method Body
{ e.Element.SetAttr(name, value) }

hostVarElement interface

Methods:

Exists


Returns:
  • bool

Text


Returns:
  • string

SetText


Parameters:
  • string

Attr


Parameters:
  • string

Returns:
  • string

SetAttr


Parameters:
  • string
  • string

componentRoot interface

Methods:

HostVar


Parameters:
  • string

Returns:
  • hostVarElement

SetHTML


Parameters:
  • string

hydrationMismatch struct

Fields:

  • VarName (string)
  • Expected (string)
  • Actual (string)
  • ActualHash (string)
  • ExpectedAlg (string)

initSnapshotPayload struct

Fields:

  • HTML (string)
  • Vars ([]string)

encodeExpectation function

Parameters:

  • value string

Returns:

  • string
Show/Hide Function Body
{
	sum := sha1.Sum([]byte(value))
	return fmt.Sprintf("%s:%s", expectationHashAlg, hex.EncodeToString(sum[:]))
}

expectationMatches function

Parameters:

  • expectedAttr string
  • actual string

Returns:

  • bool
  • string
  • string
Show/Hide Function Body
{
	actualHash := encodeExpectation(actual)
	if expectedAttr == "" {
		return true, expectationHashAlg, actualHash
	}
	if strings.HasPrefix(expectedAttr, expectationHashAlg+":") {
		return expectedAttr == actualHash, expectationHashAlg, actualHash
	}
	return expectedAttr == actual, "raw", actualHash
}

updateHostVar function

Parameters:

  • root componentRoot
  • name string
  • value string

Returns:

  • *hydrationMismatch

References:

Show/Hide Function Body
{
	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
}

handleHostPayload function

Parameters:

  • root componentRoot
  • payload map[string]any

Returns:

  • []hydrationMismatch

References:

Show/Hide Function Body
{
	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)
		}
	}
	return mismatches
}

applyInitSnapshot function

Parameters:

  • root componentRoot
  • payload *initSnapshotPayload

References:

Show/Hide Function Body
{
	if payload == nil {
		return
	}
	root.SetHTML(payload.HTML)
}

decodeInitSnapshotPayload function

Parameters:

  • raw any

Returns:

  • *initSnapshotPayload
Show/Hide Function Body
{
	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}
}

buildResyncPayload function

Parameters:

  • mismatches []hydrationMismatch

Returns:

  • map[string]any
Show/Hide Function Body
{
	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,
		},
	}
}

componentBinding struct

Fields:

  • id (string)
  • vars ([]string)

message struct

Fields:

  • name (string)
  • payload (any)

connect function

Show/Hide Function Body
{
	once.Do(func() {
		go connectionLoop()
	})
}

connectionLoop function

Show/Hide Function Body
{
	host := js.Location().Get("host").String()
	if h := js.Get("RFW_HOST"); h.Truthy() {
		host = h.String()
	}
	scheme := "wss"
	if js.Location().Get("protocol").String() == "http:" {
		scheme = "ws"
	}
	backoff := time.Second
	for {
		ctx, cancel := context.WithCancel(context.Background())
		url := fmt.Sprintf("%s://%s/ws", scheme, host)
		if debug {
			log.Printf("hostclient: dialing %s", url)
		}
		c, _, err := websocket.Dial(ctx, url, nil)
		if err != nil {
			cancel()
			time.Sleep(backoff)
			if backoff < time.Minute {
				backoff *= 2
			}
			continue
		}

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

		backoff = time.Second
		if debug {
			log.Printf("hostclient: connected")
		}

		for _, msg := range pend {
			sendMessage(c, msg)
		}
		for name := range bindings {
			sendMessage(c, message{name: name, payload: map[string]any{"init": true}})
		}

		errCh := make(chan error, 1)
		go func() { errCh <- readLoop(ctx, c) }()
		go func() { errCh <- pingLoop(ctx, c) }()
		err = <-errCh
		cancel()
		c.Close(websocket.StatusInternalError, "connection closed")

		mu.Lock()
		conn = nil
		mu.Unlock()
		if debug && err != nil {
			log.Printf("hostclient: connection closed: %v", err)
		}
	}
}

pingLoop function

Parameters:

  • ctx context.Context
  • c *websocket.Conn

Returns:

  • error
Show/Hide Function Body
{
	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()
		}
	}
}

readLoop function

Parameters:

  • ctx context.Context
  • c *websocket.Conn

Returns:

  • error
Show/Hide Function Body
{
	for {
		var msg struct {
			Component string         `json:"component"`
			Payload   map[string]any `json:"payload"`
			Session   string         `json:"session"`
		}
		if err := wsjson.Read(ctx, c, &msg); err != nil {
			return err
		}
		if debug {
			log.Printf("hostclient: recv %s %v", msg.Component, msg.Payload)
		}
		if msg.Session != "" {
			sessionMu.Lock()
			sessionID = msg.Session
			sessionMu.Unlock()
		}
		if h, ok := handlers[msg.Component]; ok {
			payload := msg.Payload
			if payload == nil {
				payload = make(map[string]any)
			}
			if msg.Session != "" {
				payload["_session"] = msg.Session
			}
			h(payload)
			continue
		}
		if b, ok := bindings[msg.Component]; ok {
			rootEl := dom.Doc().Query(fmt.Sprintf("[data-component-id='%s']", b.id))
			if !rootEl.Truthy() {
				continue
			}
			root := newComponentRoot(rootEl)

			if snap := decodeInitSnapshotPayload(msg.Payload["initSnapshot"]); snap != nil {
				applyInitSnapshot(root, snap)
				if len(snap.Vars) > 0 {
					b.vars = append([]string(nil), snap.Vars...)
					bindings[msg.Component] = b
				}
				continue
			}

			mismatches := handleHostPayload(root, msg.Payload)
			if len(mismatches) > 0 {
				for _, m := range mismatches {
					log.Printf("hostclient: hydration mismatch component=%s var=%s expected=%s actualHash=%s actual=%q", msg.Component, m.VarName, m.Expected, m.ActualHash, m.Actual)
				}
				Send(msg.Component, buildResyncPayload(mismatches))
			}
		}
	}
}

RegisterComponent function

Parameters:

  • id string
  • name string
  • vars []string
Show/Hide Function Body
{
	bindings[name] = componentBinding{id: id, vars: vars}
	connect()
	Send(name, map[string]any{"init": true})
}

Send function

Parameters:

  • name string
  • payload any
Show/Hide Function Body
{
	connect()
	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})
}

RegisterHandler function

RegisterHandler installs a callback for messages targeting the component name.

Parameters:

  • name string
  • h func(map[string]any)
Show/Hide Function Body
{
	mu.Lock()
	handlers[name] = h
	mu.Unlock()
	connect()
	Send(name, map[string]any{"init": true})
}

SessionID function

SessionID returns the current WebSocket session identifier assigned by the host.

Returns:

  • string
Show/Hide Function Body
{
	sessionMu.RLock()
	defer sessionMu.RUnlock()
	return sessionID
}

sendMessage function

Parameters:

  • c *websocket.Conn
  • msg message

References:

Show/Hide Function Body
{
	m := struct {
		Component string `json:"component"`
		Payload   any    `json:"payload"`
	}{Component: msg.name, Payload: msg.payload}
	ctx := context.Background()
	_ = wsjson.Write(ctx, c, m)
}

EnableDebug function

EnableDebug prints WebSocket traffic to the console.

Show/Hide Function Body
{ debug = true }

regexp import

Import example:

import "regexp"

testing import

Import example:

import "testing"

fmt import

Import example:

import "fmt"

github.com/rfwlab/rfw/v1/dom import

Import example:

import "github.com/rfwlab/rfw/v1/dom"

Imported as:

dom

crypto/sha1 import

Import example:

import "crypto/sha1"

encoding/hex import

Import example:

import "encoding/hex"

fmt import

Import example:

import "fmt"

strings import

Import example:

import "strings"

context import

Import example:

import "context"

fmt import

Import example:

import "fmt"

log import

Import example:

import "log"

sync import

Import example:

import "sync"

time import

Import example:

import "time"

github.com/rfwlab/rfw/v1/dom import

Import example:

import "github.com/rfwlab/rfw/v1/dom"

Imported as:

dom

github.com/rfwlab/rfw/v1/js import

Import example:

import "github.com/rfwlab/rfw/v1/js"

Imported as:

js

nhooyr.io/websocket import

Import example:

import "nhooyr.io/websocket"

nhooyr.io/websocket/wsjson import

Import example:

import "nhooyr.io/websocket/wsjson"