add WASM blazor-like thing

This commit is contained in:
2026-07-13 00:40:08 -04:00
parent 98978e4930
commit 3c494605ba
38 changed files with 3070 additions and 1 deletions

93
go/rsc/client_wasm.go Normal file
View File

@@ -0,0 +1,93 @@
//go:build js && wasm
package rsc
import (
"bytes"
"encoding/gob"
"syscall/js"
"kjol/vdom"
)
// Mount connects to a server component by name and returns a render function.
// The generated client stub for a //gowasm:server component just calls this, so
// the call site is identical to a client component. It POSTs {name, state,
// event} to /rsc and swaps (reconciles) the returned render into the DOM. State
// is opaque and round-trips through the client — the server keeps nothing.
func Mount(name string) func() *vdom.VNode {
tree := vdom.NewSignal[*vdom.VNode](nil)
var state [][]byte
var post func(kind string, nodeID int, event, value string)
var convert func(*SNode) *vdom.VNode
convert = func(sn *SNode) *vdom.VNode {
if sn == nil {
return nil
}
if sn.Tag == "" {
return vdom.Text(sn.Text)
}
mods := make([]vdom.Mod, 0, len(sn.Attrs)+len(sn.Events)+len(sn.Kids)+1)
for k, v := range sn.Attrs {
mods = append(mods, vdom.Attr(k, v))
}
if sn.HTML != "" {
mods = append(mods, vdom.Raw(sn.HTML))
}
for _, ev := range sn.Events {
event, id := ev, sn.ID
mods = append(mods, vdom.OnEvent(ev, func(e vdom.Event) { post("event", id, event, e.Value()) }))
}
for _, k := range sn.Kids {
if c := convert(k); c != nil {
mods = append(mods, c)
}
}
return vdom.El(sn.Tag, mods...)
}
onArr := js.FuncOf(func(this js.Value, args []js.Value) any {
arr := js.Global().Get("Uint8Array").New(args[0])
b := make([]byte, arr.Get("length").Int())
js.CopyBytesToGo(b, arr)
var resp Response
if gob.NewDecoder(bytes.NewReader(b)).Decode(&resp) == nil {
state = resp.State
tree.Set(convert(resp.Tree)) // re-render -> reconcile into the DOM
}
return nil
})
onResp := js.FuncOf(func(this js.Value, args []js.Value) any {
args[0].Call("arrayBuffer").Call("then", onArr)
return nil
})
loc := js.Global().Get("location")
url := loc.Get("protocol").String() + "//" + loc.Get("host").String() + "/rsc"
post = func(kind string, nodeID int, event, value string) {
var buf bytes.Buffer
if gob.NewEncoder(&buf).Encode(Request{Kind: kind, Name: name, State: state, NodeID: nodeID, Event: event, Value: value}) != nil {
return
}
body := js.Global().Get("Uint8Array").New(buf.Len())
js.CopyBytesToJS(body, buf.Bytes())
opts := js.Global().Get("Object").New()
opts.Set("method", "POST")
opts.Set("body", body)
js.Global().Call("fetch", url, opts).Call("then", onResp)
}
started := false
return func() *vdom.VNode {
if !started {
started = true
post("mount", 0, "", "")
}
if t := tree.Get(); t != nil {
return t
}
return vdom.Div(vdom.Attr("class", "text-muted"), vdom.Text("loading server component…"))
}
}

38
go/rsc/proto.go Normal file
View File

@@ -0,0 +1,38 @@
// Package rsc runs server components with a stateless, React/Next-style
// request/response: the client POSTs the component name, its (opaque) signal
// state, and any triggered event; the server restores the state, applies the
// event, re-renders, and returns the new state + rendered tree, which the client
// reconciles ("swaps") into the DOM. No persistent connection, no server-held
// session — state round-trips through the client.
//
// This file holds the wire types (gob), shared by server and client.
package rsc
// SNode is a serialized render node. Nodes with server handlers carry an ID and
// their event names; the client sends {name, state, ID, event} back so the
// server can find and invoke the handler.
type SNode struct {
ID int
Tag string
Text string
HTML string
Attrs map[string]string
Events []string
Kids []*SNode
}
// Request is client -> server (every request is self-contained).
type Request struct {
Kind string // "mount" | "event"
Name string // component name
State [][]byte // opaque signal snapshot from the previous response
NodeID int // event: node that fired
Event string // event: event name
Value string // event: target value (inputs)
}
// Response is server -> client.
type Response struct {
State [][]byte
Tree *SNode
}

95
go/rsc/server.go Normal file
View File

@@ -0,0 +1,95 @@
//go:build !(js && wasm)
package rsc
import (
"encoding/gob"
"net/http"
"sort"
"sync"
"kjol/vdom"
)
// registry maps a server-component name to its factory (created by generated
// code — one Register per //gowasm:server component). The factory builds the
// component's signals + render closure; here it's re-run per request (stateless).
var registry = map[string]func() func() *vdom.VNode{}
func Register(name string, factory func() func() *vdom.VNode) { registry[name] = factory }
// renderMu serializes server renders because the vdom signal Collector is a
// process-global (see vdom.BeginCollect). Fine for this scale.
var renderMu sync.Mutex
// Handler is the single /rsc endpoint. It restores the component's signals from
// the request, applies the event (if any), re-renders, and returns the new
// signal snapshot + tree.
func Handler(w http.ResponseWriter, r *http.Request) {
var req Request
if gob.NewDecoder(r.Body).Decode(&req) != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
factory := registry[req.Name]
if factory == nil {
http.NotFound(w, r)
return
}
renderMu.Lock()
c := vdom.BeginCollect(req.State) // restore prior signal values (nil on mount)
render := factory() // signals created + restored here
vdom.EndCollect()
if req.Kind == "event" {
// Reproduce the tree the client currently shows (same state) to find the
// handler by node id, then invoke it (mutating signals).
_, handlers := renderIDs(render())
if hm := handlers[req.NodeID]; hm != nil {
if h := hm[req.Event]; h != nil {
h(serverEvent{value: req.Value})
}
}
}
tree, _ := renderIDs(render()) // render the result (reflects the mutation)
resp := Response{State: c.Snapshot(), Tree: tree}
renderMu.Unlock()
w.Header().Set("Content-Type", "application/octet-stream")
_ = gob.NewEncoder(w).Encode(resp)
}
// renderIDs serializes a VNode tree to SNode, assigning a stable (traversal
// order) id to each node with handlers and building the handler table.
func renderIDs(root *vdom.VNode) (*SNode, map[int]map[string]func(vdom.Event)) {
handlers := map[int]map[string]func(vdom.Event){}
id := 0
var walk func(*vdom.VNode) *SNode
walk = func(v *vdom.VNode) *SNode {
sn := &SNode{Tag: v.Tag, Text: v.Text, HTML: v.HTML, Attrs: v.Attrs}
if len(v.Events) > 0 {
id++
sn.ID = id
hm := map[string]func(vdom.Event){}
for ev, h := range v.Events {
sn.Events = append(sn.Events, ev)
hm[ev] = h
}
sort.Strings(sn.Events)
handlers[id] = hm
}
if v.HTML == "" {
for _, k := range v.Children {
sn.Kids = append(sn.Kids, walk(k))
}
}
return sn
}
return walk(root), handlers
}
type serverEvent struct{ value string }
func (e serverEvent) PreventDefault() {}
func (e serverEvent) Value() string { return e.value }