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

77
go/wasmruntime/fetch.go Normal file
View File

@@ -0,0 +1,77 @@
//go:build js && wasm
package wasmruntime
import (
"errors"
"fmt"
"sync"
"syscall/js"
)
// Fetch performs an HTTP GET via the browser Fetch API and returns the response
// body as a string. It bridges a JS Promise into Go: it registers then/catch
// callbacks and blocks the calling goroutine on a channel until the request
// settles.
//
// Because it blocks, call it from a goroutine — never from a reactive callback
// or event handler — and write the result into signals when it returns, e.g.:
//
// go func() {
// body, err := Fetch(url)
// ...
// data.Set(...) // updates the UI reactively
// }()
func Fetch(url string) (string, error) {
var (
once sync.Once
done = make(chan struct{})
body string
ferr error
onResp, onText, onErr js.Func
)
finish := func() { once.Do(func() { close(done) }) }
onErr = js.FuncOf(func(this js.Value, args []js.Value) any {
msg := "fetch error"
if len(args) > 0 && args[0].Truthy() {
e := args[0]
if m := e.Get("message"); m.Truthy() {
msg = m.String()
} else {
msg = e.Call("toString").String()
}
}
ferr = errors.New(msg)
finish()
return nil
})
onText = js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) > 0 {
body = args[0].String()
}
finish()
return nil
})
onResp = js.FuncOf(func(this js.Value, args []js.Value) any {
resp := args[0]
if !resp.Get("ok").Bool() {
ferr = fmt.Errorf("HTTP %d", resp.Get("status").Int())
finish()
return nil
}
// resp.text() -> Promise<string>
resp.Call("text").Call("then", onText).Call("catch", onErr)
return nil
})
js.Global().Call("fetch", url).Call("then", onResp).Call("catch", onErr)
<-done
onResp.Release()
onText.Release()
onErr.Release()
return body, ferr
}

86
go/wasmruntime/mount.go Normal file
View File

@@ -0,0 +1,86 @@
//go:build js && wasm
package wasmruntime
import (
"syscall/js"
"kjol/vdom"
)
var (
rootRender func()
renderScheduled bool
disposed bool
flushFunc js.Func
flushInited bool
)
// HasServerContent reports whether #app already holds server-rendered markup
// (so the client should Hydrate rather than build from scratch).
func HasServerContent() bool {
return document.Call("getElementById", "app").Get("firstChild").Truthy()
}
// Run mounts a component fresh (client-side rendering). The component returns a
// VNode tree; any signal write re-renders and reconciles into the DOM.
func Run(component func() *vdom.VNode) {
vdom.Schedule = scheduleRender
root := document.Call("getElementById", "app")
var prev *vdom.VNode
rootRender = func() {
next := component()
patchChildren(root, one(prev), one(next))
prev = next
}
rootRender()
finish(root)
}
// Hydrate attaches to server-rendered DOM: it renders the same initial tree the
// server did, adopts the existing nodes (wiring events, no re-creation), then
// re-renders reactively from there.
func Hydrate(component func() *vdom.VNode) {
vdom.Schedule = scheduleRender
root := document.Call("getElementById", "app")
prev := component()
hydrateNode(root.Get("firstChild"), prev)
rootRender = func() {
next := component()
patchChildren(root, one(prev), one(next))
prev = next
}
finish(root)
}
func finish(root js.Value) {
// Allow a JS loader to tear this instance down before an in-place hot swap:
// snapshot signal state (so the next instance restores it), then clear #app.
js.Global().Set("__gowasmDispose", js.FuncOf(func(this js.Value, args []js.Value) any {
disposed = true
saveState()
root.Set("innerHTML", "")
return nil
}))
select {} // keep the runtime alive for event callbacks
}
func scheduleRender() {
if disposed || rootRender == nil || renderScheduled {
return
}
renderScheduled = true
js.Global().Call("queueMicrotask", ensureFlush())
}
func ensureFlush() js.Func {
if !flushInited {
flushFunc = js.FuncOf(func(this js.Value, args []js.Value) any {
renderScheduled = false
rootRender()
return nil
})
flushInited = true
}
return flushFunc
}

View File

@@ -0,0 +1,68 @@
//go:build js && wasm
package wasmruntime
import (
"syscall/js"
"kjol/vdom"
)
// State preservation across an in-place hot swap (see the dev server's
// livereload script). A fresh wasm instance has fresh Go memory, so signal
// values would reset on every rebuild. To keep them, the outgoing instance
// snapshots its signals (by creation order, via a vdom.Collector) into
// sessionStorage on dispose, and the incoming instance restores them on boot.
//
// It's best-effort: if an edit adds/removes/reorders signals, the by-order match
// shifts and unmatched signals fall back to their initial value — the same
// hook-order caveat as server components.
const stateKey = "__gowasm_hmr_state"
var stateCollector *vdom.Collector
// PreserveState registers the collector whose signals are snapshotted to
// sessionStorage when this instance is disposed for a hot swap.
func PreserveState(c *vdom.Collector) { stateCollector = c }
// RestoreState returns the signal snapshot saved by a prior instance before a
// hot swap, or nil if there is none. It is one-shot: the entry is cleared so a
// manual page refresh starts from initial state.
func RestoreState() [][]byte {
ss := js.Global().Get("sessionStorage")
if !ss.Truthy() {
return nil
}
raw := ss.Call("getItem", stateKey)
if !raw.Truthy() {
return nil
}
ss.Call("removeItem", stateKey)
arr := js.Global().Get("JSON").Call("parse", raw)
if arr.Type() != js.TypeObject {
return nil
}
out := make([][]byte, arr.Length())
for i := range out {
out[i] = []byte(arr.Index(i).String())
}
return out
}
// saveState writes the preserved collector's current signal values to
// sessionStorage. Called from the dispose hook before the DOM is torn down.
func saveState() {
if stateCollector == nil {
return
}
ss := js.Global().Get("sessionStorage")
if !ss.Truthy() {
return
}
arr := js.Global().Get("Array").New()
for _, b := range stateCollector.Snapshot() {
arr.Call("push", string(b))
}
ss.Call("setItem", stateKey, js.Global().Get("JSON").Call("stringify", arr))
}

228
go/wasmruntime/reconcile.go Normal file
View File

@@ -0,0 +1,228 @@
//go:build js && wasm
// Package wasmruntime is the wasm client runtime for the neutral vdom package: it
// reconciles vdom.VNode trees into the real DOM (fresh mount or hydration of
// server-rendered DOM), and drives re-renders when signals change.
package wasmruntime
import (
"syscall/js"
"kjol/vdom"
)
var document = js.Global().Get("document")
// nodeRT is the per-node bookkeeping stored in VNode.Runtime (wasm only).
type nodeRT struct {
dom js.Value
jsFuncs map[string]js.Func
refs map[string]*handlerRef
}
type handlerRef struct{ fn func(vdom.Event) }
func rt(n *vdom.VNode) *nodeRT {
if n.Runtime == nil {
n.Runtime = &nodeRT{jsFuncs: map[string]js.Func{}, refs: map[string]*handlerRef{}}
}
return n.Runtime.(*nodeRT)
}
// clientEvent adapts a DOM event to vdom.Event.
type clientEvent struct{ js js.Value }
func (e clientEvent) PreventDefault() { e.js.Call("preventDefault") }
func (e clientEvent) Value() string {
t := e.js.Get("target")
if !t.Truthy() {
return ""
}
v := t.Get("value")
if !v.Truthy() {
return "" // e.g. a button has no value
}
return v.String()
}
func one(n *vdom.VNode) []*vdom.VNode {
if n == nil {
return nil
}
return []*vdom.VNode{n}
}
// ---- fresh create + diff ----
func createDOM(n *vdom.VNode) js.Value {
if n.Tag == "" {
d := document.Call("createTextNode", n.Text)
rt(n).dom = d
return d
}
el := document.Call("createElement", n.Tag)
rt(n).dom = el
for k, v := range n.Attrs {
el.Call("setAttribute", k, v)
}
for k, v := range n.Props {
el.Set(k, v)
}
for name, h := range n.Events {
addListener(n, name, h)
}
if n.HTML != "" {
el.Set("innerHTML", n.HTML)
return el
}
for _, c := range n.Children {
el.Call("appendChild", createDOM(c))
}
return el
}
func patchChildren(parent js.Value, old, next []*vdom.VNode) {
n := max(len(old), len(next))
for i := range n {
var o, x *vdom.VNode
if i < len(old) {
o = old[i]
}
if i < len(next) {
x = next[i]
}
patch(parent, o, x)
}
}
func patch(parent js.Value, o, x *vdom.VNode) {
switch {
case o == nil && x == nil:
return
case o == nil:
parent.Call("appendChild", createDOM(x))
case x == nil:
parent.Call("removeChild", rt(o).dom)
release(o)
case o.Tag != x.Tag:
parent.Call("replaceChild", createDOM(x), rt(o).dom)
release(o)
default:
x.Runtime = o.Runtime // adopt dom + listeners
dom := rt(x).dom
if x.Tag == "" {
if x.Text != o.Text {
dom.Set("nodeValue", x.Text)
}
return
}
updateAttrs(o, x)
updateProps(o, x)
updateEvents(o, x)
if x.HTML != "" {
if x.HTML != o.HTML {
dom.Set("innerHTML", x.HTML)
}
return
}
patchChildren(dom, o.Children, x.Children)
}
}
func updateAttrs(o, x *vdom.VNode) {
dom := rt(x).dom
for k := range o.Attrs {
if _, ok := x.Attrs[k]; !ok {
dom.Call("removeAttribute", k)
}
}
for k, v := range x.Attrs {
if o.Attrs[k] != v {
dom.Call("setAttribute", k, v)
}
}
}
func updateProps(o, x *vdom.VNode) {
dom := rt(x).dom
for k, v := range x.Props {
if o.Props[k] != v && dom.Get(k).String() != v {
dom.Set(k, v)
}
}
}
func updateEvents(o, x *vdom.VNode) {
r := rt(x) // same nodeRT as o (adopted above)
for name, fn := range r.jsFuncs {
if _, ok := x.Events[name]; !ok {
r.dom.Call("removeEventListener", name, fn)
fn.Release()
delete(r.jsFuncs, name)
delete(r.refs, name)
}
}
for name, h := range x.Events {
if ref, ok := r.refs[name]; ok {
ref.fn = h
} else {
addListener(x, name, h)
}
}
}
func addListener(n *vdom.VNode, name string, handler func(vdom.Event)) {
r := rt(n)
ref := &handlerRef{fn: handler}
fn := js.FuncOf(func(this js.Value, args []js.Value) any {
var ev js.Value
if len(args) > 0 {
ev = args[0]
}
ref.fn(clientEvent{js: ev})
return nil
})
r.dom.Call("addEventListener", name, fn)
r.jsFuncs[name] = fn
r.refs[name] = ref
}
func release(n *vdom.VNode) {
if n.Runtime != nil {
for _, fn := range rt(n).jsFuncs {
fn.Release()
}
}
for _, c := range n.Children {
release(c)
}
}
// ---- hydration: adopt server-rendered DOM instead of creating it ----
func hydrateNode(dom js.Value, n *vdom.VNode) {
if !dom.Truthy() {
return // structural mismatch; leave a hole (a later re-render will fix)
}
rt(n).dom = dom
if n.Tag == "" {
if dom.Get("nodeValue").String() != n.Text {
dom.Set("nodeValue", n.Text)
}
return
}
for name, h := range n.Events {
addListener(n, name, h)
}
for k, v := range n.Props {
dom.Set(k, v)
}
if n.HTML != "" {
return // trust server-rendered HTML
}
childNodes := dom.Get("childNodes")
for i, c := range n.Children {
hydrateNode(childNodes.Index(i), c)
}
}

38
go/wasmruntime/router.go Normal file
View File

@@ -0,0 +1,38 @@
//go:build js && wasm
package wasmruntime
import (
"syscall/js"
"kjol/vdom"
)
// Router holds the current path in a signal, so reading Path() during render
// re-renders on navigation (and browser back/forward).
type Router struct {
path *vdom.Signal[string]
}
func NewRouter() *Router {
r := &Router{path: vdom.NewSignal(currentPath())}
popstate := js.FuncOf(func(this js.Value, args []js.Value) any {
r.path.Set(currentPath())
return nil
})
js.Global().Call("addEventListener", "popstate", popstate)
return r
}
func currentPath() string {
return js.Global().Get("location").Get("pathname").String()
}
// Path returns the current route (reactive when read during render).
func (r *Router) Path() string { return r.path.Get() }
// Navigate pushes a history entry and re-renders (client-side SPA navigation).
func (r *Router) Navigate(path string) {
js.Global().Get("history").Call("pushState", nil, "", path)
r.path.Set(path)
}

View File

@@ -0,0 +1,7 @@
//go:build !(js && wasm)
// Package wasmruntime is the browser-side client runtime (reconciler, mount /
// hydrate, router, fetch, hot-reload state preservation). Its implementation
// compiles only under GOOS=js GOARCH=wasm; this placeholder keeps the package
// non-empty on other platforms so `go build ./...` / `go vet ./...` succeed.
package wasmruntime