// Package livepage serves a go-app component as a live HTML page. The browser // connects over a WebSocket (github.com/coder/websocket), forwards DOM events // (click, change, submit) to the model, and receives re-rendered HTML, which is // morphed into the document using Idiomorph. jQuery and Idiomorph are served by // the package and injected into the <head> of every page. // // Usage: // // type Model struct { // livepage.ModelBase // mach *am.Machine // } // // implement Render and Mach; use go-app event handlers like OnClick // // page, err := livepage.New(ctx, model, livepage.Opts{}) // http.ListenAndServe(":8080", page.Handler()) // // State changes outside of DOM events should call [Page.HRefresh]. // // Enable [Opts.DiffHtml] to push compact HTML diffs instead of full documents.
package livepage import ( amhelp am h ) //go:embed assets/jquery.js assets/idiomorph.min.js assets/livepage.js var assetsFs embed.FS const ( // DefaultDebounce is the default delay of pushing HTML to the clients. DefaultDebounce = 200 * time.Millisecond // PathWs is the WebSocket endpoint, relative to [Opts.Prefix]. PathWs = "/ws" // PathAssets is the directory with served scripts, relative to // [Opts.Prefix]. PathAssets = "/assets/" evalSource = "livepage.dom" maxMsgSize = 1 << 20 ) // DomEvent is a DOM event forwarded from the browser. type DomEvent struct { // Type is one of click, change, submit. Type string // attrs Id string Name string Value string // data-livepage attr Data string // Handler is the ID of a go-app event handler bound to the target. Handler string // event payload Key string `json:",omitempty"` Code string `json:",omitempty"` Button int `json:",omitempty"` ClientX float64 `json:",omitempty"` ClientY float64 `json:",omitempty"` DeltaX float64 `json:",omitempty"` DeltaY float64 `json:",omitempty"` ScrollTop float64 `json:",omitempty"` ScrollLeft float64 `json:",omitempty"` // meta Path string // internal Event *am.Event `json:"-"` } type htmlMsg struct { Type string `json:"type"` Html string `json:"html"` } type diffMsg struct { Type string `json:"type"` Ops []diffOp `json:"ops"` // Hash is the FNV-1a hash of the resulting HTML. Hash string `json:"hash"` } type diffOp struct { // Op is -1 delete, 1 insert. Op int8 `json:"op"` // Pos is a UTF-16 offset in the previous HTML document. Pos int `json:"pos"` // Len is the number of UTF-16 units to delete. Len int `json:"len,omitempty"` // Text is inserted content. Text string `json:"text,omitempty"` } // ///// ///// ///// // ///// COMPO // ///// ///// ///// // CompoLiveApi is a live component. It has to embed [Compo] (or h.Compo and // implement Update on its own). type CompoLiveApi interface { h.Composer // Mach is the machine used to serialize DOM handlers and rendering. Mach() *am.Machine // Update flags the component for re-render. Update() PageName() string } type Compo struct { h.Compo app atomic.Pointer[h.Context] } // OnMount stores the component's context. func ( *Compo) ( h.Context) { .app.Store(&) } // App returns the component's app context, if mounted. func ( *Compo) () *h.Context { return .app.Load() } // Update flags the component for re-render. No-op until mounted. func ( *Compo) () { if := .app.Load(); != nil { .Update() } } func ( *Compo) () string { return "livepage" } // Opts configures a [Page]. type Opts struct { // HtmlPage wraps the rendered component into a whole HTML document. Scripts // are injected into its <head>. Defaults to [DefaultHtmlPage]. HtmlPage func(body string) string // Debounce delay of pushing HTML to the clients. Defaults to // [DefaultDebounce]. Debounce time.Duration // DiffHtml sends HTML diffs instead of full documents, using go-diff on // the server and a JS equivalent in the browser. Default false. DiffHtml bool // Prefix of all the paths served by [Page.NewHandler], eg "/live". Optional. Prefix string // OriginPatterns are additionally allowed WebSocket origins (host // patterns). Same origin is always allowed. OriginPatterns []string // Logger defaults to slog.Default(). Logger *slog.Logger } // DefaultHtmlPage is a minimal HTML document. func ( string) string { return "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>livepage</title></head><body>" + + "</body></html>" } // ///// ///// ///// // ///// PAGE // ///// ///// ///// // Page binds a [CompoLiveApi] to many browsers. type Page[ CompoLiveApi] struct { compo ctx context.Context renderer h.TestEngine opts Opts log *slog.Logger latestHtml atomic.Pointer[string] pushMx sync.Mutex pushTimer *time.Timer // pushNowMx serializes renders/diffs, keeping latestHtml ordered. pushNowMx sync.Mutex // TODO slices, not a map clientsMx sync.Mutex clients map[*websocket.Conn]struct{} handlersMx sync.RWMutex handlers map[string]h.EventHandler prevHandlers map[string]h.EventHandler Disposed atomic.Bool } // New mounts the model and creates a [Page], which lives as long as ctx. func [ CompoLiveApi]( context.Context, , Opts) (*Page[], error) { if .HtmlPage == nil { .HtmlPage = DefaultHtmlPage } if .Debounce <= 0 { .Debounce = DefaultDebounce } .Prefix = strings.TrimRight(.Prefix, "/") // TODO check if compo mach implements schema (Prefix.Connected, Prefix.Basic) := .Logger if == nil { = slog.Default() } := &Page[]{ compo: , ctx: , renderer: h.NewTestEngine(), opts: , log: , clients: map[*websocket.Conn]struct{}{}, handlers: map[string]h.EventHandler{}, prevHandlers: map[string]h.EventHandler{}, } := .renderer.Load() if != nil { return nil, } .renderer.ConsumeNext() if .Err() != nil { return nil, .Err() // expired } // dispose .Mach().OnDispose(func( string, context.Context) { .Dispose() }) return , nil } func ( *Page[]) () { .Disposed.Store(true) .stopPush() .closeClients() } // Compo returns the underlying component. func ( *Page[]) () { return .compo } // Clients returns the number of connected browsers. func ( *Page[]) () int { .clientsMx.Lock() defer .clientsMx.Unlock() return len(.clients) } // Push schedules (debounced) pushing of the current HTML to all the clients. // Nothing is sent when the HTML didn't change. func ( *Page[]) () { // TODO accept html string, dont eval in pushNow .pushMx.Lock() defer .pushMx.Unlock() if .ctx.Err() != nil { return // expired } if .pushTimer != nil { .pushTimer.Stop() } .pushTimer = time.AfterFunc(.opts.Debounce, .pushNow) } // stopPush cancels a scheduled push. func ( *Page[]) () { .pushMx.Lock() defer .pushMx.Unlock() if .pushTimer != nil { .pushTimer.Stop() } } // HRefresh re-renders the model and pushes the HTML. It is safe to call from // state handlers and Eval(). func ( *Page[]) () { .compo.Update() .renderer.ConsumeAll() .Push() } // Refresh is threadsafe version of HRefresh and should not be called from state handlers or eval. func ( *Page[]) ( context.Context) { .compo.Mach().Eval("livepage-"+.compo.PageName(), .HRefresh, ) } // NewHandler serves the page, scripts and the WebSocket endpoint. func ( *Page[]) () http.Handler { := .opts.Prefix := http.NewServeMux() .HandleFunc("GET "++"/{$}", .servePage) .HandleFunc("GET "++PathWs, .serveWs) , := fs.Sub(assetsFs, "assets") .Handle("GET "++PathAssets, http.StripPrefix(+PathAssets, http.FileServerFS())) return } // HTML returns the full HTML document with the current state, as sent to the // browsers. It returns false when rendering timed out. func ( *Page[]) () (string, bool) { var string := .compo.Mach().Eval(evalSource, func() { .handlersMx.Lock() .prevHandlers = .handlers .handlers = map[string]h.EventHandler{} .handlersMx.Unlock() = h.HTMLStringWithEvents(.compo, .registerHandler) }, .ctx) if ! { return "", false } return injectHead(.opts.HtmlPage(), .headTags()), true } // currentHtml returns the last rendered HTML, rendering on demand. func ( *Page[]) () (string, bool) { .pushNowMx.Lock() defer .pushNowMx.Unlock() if := .latestHtml.Load(); != nil { return *, true } , := .HTML() if { .latestHtml.Store(&) } return , } // registerHandler binds a go-app event handler and tags the element with its ID. func ( *Page[]) ( h.HTML, string, h.EventHandler, ) bool { if == nil { return false } := amhelp.RandId(8) .handlersMx.Lock() .handlers[] = .handlersMx.Unlock() .SetAttr("data-livepage-"+, ) return true } func ( *Page[]) () string { := .opts.Prefix + PathAssets return `<script src="` + + `jquery.js"></script>` + `<script src="` + + `idiomorph.min.js"></script>` + `<script src="` + + `livepage.js" data-ws="` + .opts.Prefix + PathWs + `"></script>` } func ( *Page[]) () { .pushNowMx.Lock() defer .pushNowMx.Unlock() , := .HTML() if ! { .log.Warn("livepage: render timeout") return } := .latestHtml.Load() if != nil && * == { return } .latestHtml.Store(&) if .opts.DiffHtml && != nil { .broadcastDiff(*, ) return } .broadcast() } func ( *Page[]) ( string) { .clientsMx.Lock() := slices.Collect(maps.Keys(.clients)) .clientsMx.Unlock() for , := range { .sendHtml(, ) } } func ( *Page[]) (, string) { := diffmatchpatch.New() .DiffTimeout = 100 * time.Millisecond := .DiffMain(, , true) := make([]diffOp, 0, len()) := 0 for , := range { switch .Type { case diffmatchpatch.DiffEqual: += utf16Len(.Text) case diffmatchpatch.DiffDelete: := utf16Len(.Text) = append(, diffOp{Op: -1, Pos: , Len: }) += case diffmatchpatch.DiffInsert: = append(, diffOp{Op: 1, Pos: , Text: .Text}) } } := diffMsg{ Type: "diff", Ops: , Hash: htmlHash(), } .clientsMx.Lock() := slices.Collect(maps.Keys(.clients)) .clientsMx.Unlock() for , := range { .send(, ) } } func ( *Page[]) ( *websocket.Conn, string) { .send(, htmlMsg{Type: "html", Html: }) } func ( *Page[]) ( *websocket.Conn, any) { , := json.Marshal() if != nil { .log.Warn("livepage: marshal", "err", ) return } , := context.WithTimeout(.ctx, 5*time.Second) defer () if := .Write(, websocket.MessageText, ); != nil { .log.Debug("livepage: write", "err", ) _ = .CloseNow() } } func ( *Page[]) () { .clientsMx.Lock() defer .clientsMx.Unlock() for := range .clients { _ = .Close(websocket.StatusGoingAway, "page disposed") delete(.clients, ) } } func ( *Page[]) ( http.ResponseWriter, *http.Request) { if .Disposed.Load() { http.Error(, "page disposed", http.StatusServiceUnavailable) return } .pushNowMx.Lock() , := .HTML() if { .latestHtml.Store(&) } .pushNowMx.Unlock() if ! { http.Error(, "render timeout", http.StatusServiceUnavailable) return } .Header().Set("Content-Type", "text/html; charset=utf-8") .Header().Set("Cache-Control", "no-store") _, _ = .Write([]byte()) } func ( *Page[]) ( http.ResponseWriter, *http.Request) { if .Disposed.Load() { http.Error(, "page disposed", http.StatusServiceUnavailable) return } , := websocket.Accept(, , &websocket.AcceptOptions{ OriginPatterns: .opts.OriginPatterns, }) if != nil { return // Accept already responded } defer .CloseNow() .SetReadLimit(maxMsgSize) .clientsMx.Lock() .clients[] = struct{}{} .clientsMx.Unlock() defer func() { .clientsMx.Lock() delete(.clients, ) .clientsMx.Unlock() }() // sync the current state if , := .currentHtml(); { .sendHtml(, ) } // wait for DOM events for { , , := .Read(.ctx) if .ctx.Err() != nil { return // expired } if != nil { if websocket.CloseStatus() == -1 { .log.Debug("livepage: read", "err", ) } return } var DomEvent if := json.Unmarshal(, &); != nil { continue } if .Type == "redraw" { if , := .currentHtml(); { .sendHtml(, ) } continue } .Event = am.CtxToEv(.ctx) .dispatch(&) } } // dispatch runs the DOM handlers and re-renders on the machine's queue, then // pushes the new HTML. func ( *Page[]) ( *DomEvent) { // TODO state := .compo.Mach().Eval(evalSource, func() { if .Handler != "" { .handlersMx.RLock() := .handlers[.Handler] if == nil { = .prevHandlers[.Handler] } .handlersMx.RUnlock() if != nil { := h.Context{} if , := any(.compo).(interface{ () h.Context }); { = .() } := map[string]string{ "type": .Type, "id": .Id, "name": .Name, "value": .Value, "data": .Data, "path": .Path, "key": .Key, "code": .Code, "button": strconv.Itoa(.Button), "clientX": strconv.FormatFloat(.ClientX, 'f', -1, 64), "clientY": strconv.FormatFloat(.ClientY, 'f', -1, 64), "deltaX": strconv.FormatFloat(.DeltaX, 'f', -1, 64), "deltaY": strconv.FormatFloat(.DeltaY, 'f', -1, 64), "scrollTop": strconv.FormatFloat(.ScrollTop, 'f', -1, 64), "scrollLeft": strconv.FormatFloat(.ScrollLeft, 'f', -1, 64), } (, h.Event{Data: }) } } // render .compo.Update() .renderer.ConsumeAll() }, .ctx) if ! { return } .Push() } // ///// ///// ///// // ///// UTILS // ///// ///// ///// // ToRef dumps a reference for a node within a DOM chain for further processing inside deferred funcs. It prevents the // chain from being broken up. func [ h.UI]( *, ) { * = return } // injectHead adds tags at the end of <head> (or at the start of the document). func injectHead(, string) string { := strings.Index(strings.ToLower(), "</head>") if < 0 { return + } return [:] + + [:] } func htmlHash( string) string { := fnv.New32a() _, _ = .Write([]byte()) return strconv.FormatUint(uint64(.Sum32()), 16) } func utf16Len( string) int { := 0 for , := range { += utf16.RuneLen() } return }