{ return e.exists }
{ return e.text }
{ e.text = v }
{
if name == hostExpectedAttr {
return e.expected
}
if e.attrStore != nil {
return e.attrStore[name]
}
return ""
}
{
if name == hostExpectedAttr {
e.expected = value
return
}
if e.attrStore == nil {
e.attrStore = make(map[string]string)
}
e.attrStore[name] = value
}
{
if el, ok := r.elems[name]; ok {
return el
}
return &fakeElement{}
}
{
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}
}
}
{
return &fakeRoot{elems: make(map[string]*fakeElement)}
}
{
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")
}
}
}
{
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")
}
}
{
selector := fmt.Sprintf(`[%s="%s"]`, hostVarAttr, name)
return domHostVarElement{r.Element.Query(selector)}
}
{
r.Element.SetHTML(html)
}
{
return domComponentRoot{el}
}
{ return e.Value.Truthy() }
{ return e.Element.Text() }
{ e.Element.SetText(value) }
{ return e.Element.Attr(name) }
{ e.Element.SetAttr(name, value) }
{
sum := sha1.Sum([]byte(value))
return fmt.Sprintf("%s:%s", expectationHashAlg, hex.EncodeToString(sum[:]))
}
{
actualHash := encodeExpectation(actual)
if expectedAttr == "" {
return true, expectationHashAlg, actualHash
}
if strings.HasPrefix(expectedAttr, expectationHashAlg+":") {
return expectedAttr == actualHash, expectationHashAlg, actualHash
}
return expectedAttr == actual, "raw", actualHash
}
{
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
}
{
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
}
{
if payload == nil {
return
}
root.SetHTML(payload.HTML)
}
{
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}
}
{
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,
},
}
}
{
once.Do(func() {
go connectionLoop()
})
}
{
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)
}
}
}
{
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()
}
}
}
{
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))
}
}
}
}
{
bindings[name] = componentBinding{id: id, vars: vars}
connect()
Send(name, map[string]any{"init": true})
}
{
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 installs a callback for messages targeting the component name.
{
mu.Lock()
handlers[name] = h
mu.Unlock()
connect()
Send(name, map[string]any{"init": true})
}
SessionID returns the current WebSocket session identifier assigned by the host.
{
sessionMu.RLock()
defer sessionMu.RUnlock()
return sessionID
}
{
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 prints WebSocket traffic to the console.
{ debug = true }
import "regexp"
import "testing"
import "fmt"
import "github.com/rfwlab/rfw/v1/dom"
dom
import "crypto/sha1"
import "encoding/hex"
import "fmt"
import "strings"
import "context"
import "fmt"
import "log"
import "sync"
import "time"
import "github.com/rfwlab/rfw/v1/dom"
dom
import "github.com/rfwlab/rfw/v1/js"
js
import "nhooyr.io/websocket"
import "nhooyr.io/websocket/wsjson"