{
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)
}
resp.Body.Close()
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)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
{
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)
}
mux := NewMux(clientDir)
srv := httptest.NewServer(mux)
defer srv.Close()
resp, err := http.Get(srv.URL + "/app.wasm.br")
if err != nil {
t.Fatalf("failed to get wasm: %v", err)
}
defer resp.Body.Close()
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 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))
}
}
{
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
}
Start launches HTTP and HTTPS servers serving files from root.
The HTTPS port is the HTTP port + 1.
{
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)
}
{
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
}
}
TestHostComponent verifies registration and handler execution.
{
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)
}
}
{
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 checks environment variable parsing.
{
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 ensures a certificate is generated.
{
cert, err := generateSelfSignedCert()
if err != nil {
t.Fatalf("generateSelfSignedCert returned error: %v", err)
}
if len(cert.Certificate) == 0 {
t.Fatalf("expected certificate data")
}
}
{
r.status = code
r.ResponseWriter.WriteHeader(code)
}
{
if h, ok := r.ResponseWriter.(http.Hijacker); ok {
return h.Hijack()
}
return nil, nil, errors.New("http.Hijacker not supported")
}
{
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))
})
}
{
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 returns an HTTP mux that serves static files from root and the
WebSocket handler at /ws.
{
root = resolveRoot(root)
staticRoot := filepath.Join(root, "..", "static")
mux := http.NewServeMux()
fs := http.FileServer(http.Dir(root))
var sfs http.Handler
if _, err := os.Stat(staticRoot); err == nil {
sfs = http.FileServer(http.Dir(staticRoot))
}
if sfs != nil {
mux.Handle("/static/", http.StripPrefix("/static", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
setWasmEncodingHeaders(w, r.URL.Path)
sfs.ServeHTTP(w, r)
})))
}
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if sfs != nil {
spath := filepath.Join(staticRoot, r.URL.Path)
if st, err := os.Stat(spath); err == nil && !st.IsDir() {
setWasmEncodingHeaders(w, spath)
sfs.ServeHTTP(w, r)
return
}
}
path := filepath.Join(root, r.URL.Path)
if st, err := os.Stat(path); err == nil && !st.IsDir() {
setWasmEncodingHeaders(w, path)
fs.ServeHTTP(w, r)
return
}
http.ServeFile(w, r, filepath.Join(root, "index.html"))
})
mux.Handle("/ws", websocket.Handler(wsHandler))
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)
}
return mux
}
ListenAndServe starts an HTTP server using NewMux to serve files and the
WebSocket endpoint.
{
logger.Info("serving HTTP", "addr", addr)
return http.ListenAndServe(addr, loggingMiddleware(NewMux(root)))
}
ListenAndServeWithMux starts an HTTP server using the provided mux.
{
logger.Info("serving HTTP", "addr", addr)
return http.ListenAndServe(addr, loggingMiddleware(mux))
}
ListenAndServeTLS starts an HTTPS server using a self-signed certificate
and NewMux to serve files and the WebSocket endpoint.
{
cert, err := generateSelfSignedCert()
if err != nil {
return err
}
srv := &http.Server{
Addr: addr,
Handler: loggingMiddleware(NewMux(root)),
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}},
}
logger.Info("serving HTTPS", "addr", addr)
return srv.ListenAndServeTLS("", "")
}
ListenAndServeTLSWithMux starts an HTTPS server using a self-signed certificate
and the provided mux, preserving any additional routes registered by callers.
{
cert, err := generateSelfSignedCert()
if err != nil {
return err
}
srv := &http.Server{
Addr: addr,
Handler: loggingMiddleware(mux),
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}},
}
logger.Info("serving HTTPS", "addr", addr)
return srv.ListenAndServeTLS("", "")
}
{
if !strings.HasSuffix(path, ".wasm.br") {
return
}
header := w.Header()
header.Set("Content-Encoding", "br")
header.Set("Content-Type", "application/wasm")
if vary := header.Get("Vary"); vary == "" {
header.Set("Vary", "Accept-Encoding")
} else if !strings.Contains(vary, "Accept-Encoding") {
header.Set("Vary", vary+", Accept-Encoding")
}
}
{
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return tls.Certificate{}, err
}
tmpl := x509.Certificate{
SerialNumber: big.NewInt(1),
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 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)
}
Session represents per-connection state for a WebSocket client.
It exposes an isolated StoreManager and a context bag for arbitrary data.
{ return s.id }
{ return s.stores }
ContextGet retrieves a value from the session context.
{
s.ctxMu.RLock()
defer s.ctxMu.RUnlock()
v, ok := s.ctx[key]
return v, ok
}
ContextSet stores a value in the session context.
{
s.ctxMu.Lock()
s.ctx[key] = value
s.ctxMu.Unlock()
}
ContextDelete removes a value from the session context.
{
s.ctxMu.Lock()
delete(s.ctx, key)
s.ctxMu.Unlock()
}
Snapshot returns a copy of all stores registered in this session.
{
return s.stores.Snapshot()
}
{
return &Session{
id: id,
stores: state.NewStoreManager(),
ctx: make(map[string]any),
}
}
{
id := generateSessionID()
session := newSession(id)
sessionMu.Lock()
sessions[id] = session
sessionMu.Unlock()
return session
}
{
if session == nil {
return
}
sessionMu.Lock()
delete(sessions, session.id)
sessionMu.Unlock()
}
SessionByID retrieves a session for the given ID.
{
sessionMu.RLock()
defer sessionMu.RUnlock()
s, ok := sessions[id]
return s, ok
}
{
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
panic(err)
}
return hex.EncodeToString(buf)
}
{
t.Helper()
registry = make(map[string]*HostComponent)
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"), 0o644); 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 {
sc.ws.Close()
}
}()
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")
}
}
{
t.Setenv("RFW_HOST_PORT", "9095")
if got := readPort(); got != 9095 {
t.Fatalf("expected override port 9095, got %d", got)
}
}
BroadcastOption configures a broadcast call.
func(*broadcastOptions)
WithSessionTarget limits a broadcast to a specific session ID.
{
return func(opts *broadcastOptions) {
opts.session = sessionID
}
}
{
session := allocateSession()
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()
releaseSession(session)
ws.Close()
}()
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 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 {
sendToConn(ws, outbound{Component: msg.Component, Payload: map[string]any{"initSnapshot": v}, Session: session.ID()})
}
continue
case InitSnapshot:
sendToConn(ws, outbound{Component: msg.Component, Payload: map[string]any{"initSnapshot": v}, Session: session.ID()})
continue
default:
sendToConn(ws, outbound{Component: msg.Component, Payload: resp, Session: session.ID()})
continue
}
}
if msg.Payload != nil && msg.Payload["init"] == true {
sendToConn(ws, outbound{
Component: msg.Component,
Session: session.ID(),
Payload: map[string]any{"session": session.ID()},
})
}
}
}
}
Broadcast sends the given payload to all connections subscribed to the component name.
{
var options broadcastOptions
for _, opt := range opts {
opt(&options)
}
connMu.RLock()
conns := connections[name]
connMu.RUnlock()
if len(conns) == 0 {
return
}
for ws, session := range conns {
if options.session != "" && session.ID() != options.session {
continue
}
sendToConn(ws, outbound{Component: name, Payload: payload, Session: session.ID()})
}
}
{
b, err := json.Marshal(out)
if err != nil {
return
}
if err := websocket.Message.Send(ws, b); err != nil {
log.Printf("send: %v", err)
}
}
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.
func(payload map[string]any) any
HandlerWithSession processes inbound payloads with the associated Session.
func(*Session, map[string]any) any
HostComponent represents server-side logic backing an HTML component.
WithInitSnapshot registers a callback that produces an InitSnapshot when a resync is requested.
{
hc.initSnapshot = fn
return hc
}
{ return hc.name }
Handle executes the component's handler.
{
if hc.handler == nil {
return nil
}
return hc.handler(payload)
}
HandleWithSession executes the session-aware handler when available.
{
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.
{ 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.
{
if session != nil {
return session.StoreManager()
}
return state.GlobalStoreManager
}
InitSnapshot represents markup the host can send to force the client to repaint a fragment.
NewHostComponent registers a handler for the given component name.
{
hc := &HostComponent{name: name, handler: handler}
if handler != nil {
hc.sessionHandler = func(_ *Session, payload map[string]any) any {
return handler(payload)
}
}
return hc
}
NewHostComponentWithSession registers a session-aware handler.
{
return &HostComponent{name: name, sessionHandler: handler}
}
Register adds a HostComponent to the global registry so incoming messages
can be routed to it.
{ registry[hc.name] = hc }
Get returns a registered HostComponent by name.
{ hc, ok := registry[name]; return hc, ok }
import "net/http"
import "net/http/httptest"
import "testing"
import "io"
import "net/http"
import "net/http/httptest"
import "os"
import "path/filepath"
import "testing"
import "encoding/json"
import "fmt"
import "os"
import "strconv"
import "strings"
import "log/slog"
import "os"
import "strings"
import "testing"
import "bufio"
import "errors"
import "net"
import "net/http"
import "time"
import "crypto/rand"
import "crypto/rsa"
import "crypto/tls"
import "crypto/x509"
import "encoding/pem"
import "expvar"
import "math/big"
import "net/http"
import "net/http/pprof"
import "os"
import "path/filepath"
import "strings"
import "time"
import "golang.org/x/net/websocket"
import "crypto/rand"
import "encoding/hex"
import "sync"
import "github.com/rfwlab/rfw/v1/state"
import "encoding/json"
import "fmt"
import "net/http/httptest"
import "os"
import "path/filepath"
import "strings"
import "sync"
import "testing"
import "golang.org/x/net/websocket"
import "github.com/rfwlab/rfw/v1/state"
import "testing"
import "encoding/json"
import "io"
import "log"
import "sync"
import "golang.org/x/net/websocket"
import "github.com/rfwlab/rfw/v1/state"