package livepage
import (
"context"
"embed"
"encoding/json"
"hash/fnv"
"io/fs"
"log/slog"
"maps"
"net/http"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf16"
"github.com/coder/websocket"
amhelp "github.com/pancsta/asyncmachine-go/pkg/helpers"
am "github.com/pancsta/asyncmachine-go/pkg/machine"
h "github.com/pancsta/go-app/pkg/app"
"github.com/sergi/go-diff/diffmatchpatch"
)
var assetsFs embed .FS
const (
DefaultDebounce = 200 * time .Millisecond
PathWs = "/ws"
PathAssets = "/assets/"
evalSource = "livepage.dom"
maxMsgSize = 1 << 20
)
type DomEvent struct {
Type string
Id string
Name string
Value string
Data string
Handler string
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"`
Path string
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 string `json:"hash"`
}
type diffOp struct {
Op int8 `json:"op"`
Pos int `json:"pos"`
Len int `json:"len,omitempty"`
Text string `json:"text,omitempty"`
}
type CompoLiveApi interface {
h .Composer
Mach () *am .Machine
Update ()
PageName () string
}
type Compo struct {
h .Compo
app atomic .Pointer [h .Context ]
}
func (c *Compo ) OnMount (ctx h .Context ) {
c .app .Store (&ctx )
}
func (c *Compo ) App () *h .Context {
return c .app .Load ()
}
func (c *Compo ) Update () {
if app := c .app .Load (); app != nil {
app .Update ()
}
}
func (c *Compo ) PageName () string {
return "livepage"
}
type Opts struct {
HtmlPage func (body string ) string
Debounce time .Duration
DiffHtml bool
Prefix string
OriginPatterns []string
Logger *slog .Logger
}
func DefaultHtmlPage (body string ) string {
return "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>livepage</title></head><body>" +
body + "</body></html>"
}
type Page [M CompoLiveApi ] struct {
compo M
ctx context .Context
renderer h .TestEngine
opts Opts
log *slog .Logger
latestHtml atomic .Pointer [string ]
pushMx sync .Mutex
pushTimer *time .Timer
pushNowMx sync .Mutex
clientsMx sync .Mutex
clients map [*websocket .Conn ]struct {}
handlersMx sync .RWMutex
handlers map [string ]h .EventHandler
prevHandlers map [string ]h .EventHandler
Disposed atomic .Bool
}
func New [M CompoLiveApi ](ctx context .Context , model M , opts Opts ) (*Page [M ], error ) {
if opts .HtmlPage == nil {
opts .HtmlPage = DefaultHtmlPage
}
if opts .Debounce <= 0 {
opts .Debounce = DefaultDebounce
}
opts .Prefix = strings .TrimRight (opts .Prefix , "/" )
log := opts .Logger
if log == nil {
log = slog .Default ()
}
p := &Page [M ]{
compo : model ,
ctx : ctx ,
renderer : h .NewTestEngine (),
opts : opts ,
log : log ,
clients : map [*websocket .Conn ]struct {}{},
handlers : map [string ]h .EventHandler {},
prevHandlers : map [string ]h .EventHandler {},
}
err := p .renderer .Load (model )
if err != nil {
return nil , err
}
p .renderer .ConsumeNext ()
if ctx .Err () != nil {
return nil , ctx .Err ()
}
model .Mach ().OnDispose (func (id string , ctx context .Context ) {
p .Dispose ()
})
return p , nil
}
func (p *Page [M ]) Dispose () {
p .Disposed .Store (true )
p .stopPush ()
p .closeClients ()
}
func (p *Page [M ]) Compo () M {
return p .compo
}
func (p *Page [M ]) Clients () int {
p .clientsMx .Lock ()
defer p .clientsMx .Unlock ()
return len (p .clients )
}
func (p *Page [M ]) Push () {
p .pushMx .Lock ()
defer p .pushMx .Unlock ()
if p .ctx .Err () != nil {
return
}
if p .pushTimer != nil {
p .pushTimer .Stop ()
}
p .pushTimer = time .AfterFunc (p .opts .Debounce , p .pushNow )
}
func (p *Page [M ]) stopPush () {
p .pushMx .Lock ()
defer p .pushMx .Unlock ()
if p .pushTimer != nil {
p .pushTimer .Stop ()
}
}
func (p *Page [M ]) HRefresh () {
p .compo .Update ()
p .renderer .ConsumeAll ()
p .Push ()
}
func (p *Page [M ]) Refresh (ctx context .Context ) {
p .compo .Mach ().Eval ("livepage-" +p .compo .PageName (), p .HRefresh , ctx )
}
func (p *Page [M ]) NewHandler () http .Handler {
pre := p .opts .Prefix
mux := http .NewServeMux ()
mux .HandleFunc ("GET " +pre +"/{$}" , p .servePage )
mux .HandleFunc ("GET " +pre +PathWs , p .serveWs )
sub , _ := fs .Sub (assetsFs , "assets" )
mux .Handle ("GET " +pre +PathAssets ,
http .StripPrefix (pre +PathAssets , http .FileServerFS (sub )))
return mux
}
func (p *Page [M ]) HTML () (string , bool ) {
var body string
ok := p .compo .Mach ().Eval (evalSource , func () {
p .handlersMx .Lock ()
p .prevHandlers = p .handlers
p .handlers = map [string ]h .EventHandler {}
p .handlersMx .Unlock ()
body = h .HTMLStringWithEvents (p .compo , p .registerHandler )
}, p .ctx )
if !ok {
return "" , false
}
return injectHead (p .opts .HtmlPage (body ), p .headTags ()), true
}
func (p *Page [M ]) currentHtml () (string , bool ) {
p .pushNowMx .Lock ()
defer p .pushNowMx .Unlock ()
if html := p .latestHtml .Load (); html != nil {
return *html , true
}
html , ok := p .HTML ()
if ok {
p .latestHtml .Store (&html )
}
return html , ok
}
func (p *Page [M ]) registerHandler (
element h .HTML , event string , handler h .EventHandler ,
) bool {
if handler == nil {
return false
}
id := amhelp .RandId (8 )
p .handlersMx .Lock ()
p .handlers [id ] = handler
p .handlersMx .Unlock ()
element .SetAttr ("data-livepage-" +event , id )
return true
}
func (p *Page [M ]) headTags () string {
pre := p .opts .Prefix + PathAssets
return `<script src="` + pre + `jquery.js"></script>` +
`<script src="` + pre + `idiomorph.min.js"></script>` +
`<script src="` + pre + `livepage.js" data-ws="` + p .opts .Prefix + PathWs + `"></script>`
}
func (p *Page [M ]) pushNow () {
p .pushNowMx .Lock ()
defer p .pushNowMx .Unlock ()
html , ok := p .HTML ()
if !ok {
p .log .Warn ("livepage: render timeout" )
return
}
prev := p .latestHtml .Load ()
if prev != nil && *prev == html {
return
}
p .latestHtml .Store (&html )
if p .opts .DiffHtml && prev != nil {
p .broadcastDiff (*prev , html )
return
}
p .broadcast (html )
}
func (p *Page [M ]) broadcast (html string ) {
p .clientsMx .Lock ()
conns := slices .Collect (maps .Keys (p .clients ))
p .clientsMx .Unlock ()
for _ , c := range conns {
p .sendHtml (c , html )
}
}
func (p *Page [M ]) broadcastDiff (prev , html string ) {
dmp := diffmatchpatch .New ()
dmp .DiffTimeout = 100 * time .Millisecond
diffs := dmp .DiffMain (prev , html , true )
ops := make ([]diffOp , 0 , len (diffs ))
pos := 0
for _ , d := range diffs {
switch d .Type {
case diffmatchpatch .DiffEqual :
pos += utf16Len (d .Text )
case diffmatchpatch .DiffDelete :
n := utf16Len (d .Text )
ops = append (ops , diffOp {Op : -1 , Pos : pos , Len : n })
pos += n
case diffmatchpatch .DiffInsert :
ops = append (ops , diffOp {Op : 1 , Pos : pos , Text : d .Text })
}
}
msg := diffMsg {
Type : "diff" ,
Ops : ops ,
Hash : htmlHash (html ),
}
p .clientsMx .Lock ()
conns := slices .Collect (maps .Keys (p .clients ))
p .clientsMx .Unlock ()
for _ , c := range conns {
p .send (c , msg )
}
}
func (p *Page [M ]) sendHtml (c *websocket .Conn , html string ) {
p .send (c , htmlMsg {Type : "html" , Html : html })
}
func (p *Page [M ]) send (c *websocket .Conn , msg any ) {
data , err := json .Marshal (msg )
if err != nil {
p .log .Warn ("livepage: marshal" , "err" , err )
return
}
ctx , cancel := context .WithTimeout (p .ctx , 5 *time .Second )
defer cancel ()
if err := c .Write (ctx , websocket .MessageText , data ); err != nil {
p .log .Debug ("livepage: write" , "err" , err )
_ = c .CloseNow ()
}
}
func (p *Page [M ]) closeClients () {
p .clientsMx .Lock ()
defer p .clientsMx .Unlock ()
for c := range p .clients {
_ = c .Close (websocket .StatusGoingAway , "page disposed" )
delete (p .clients , c )
}
}
func (p *Page [M ]) servePage (w http .ResponseWriter , _ *http .Request ) {
if p .Disposed .Load () {
http .Error (w , "page disposed" , http .StatusServiceUnavailable )
return
}
p .pushNowMx .Lock ()
html , ok := p .HTML ()
if ok {
p .latestHtml .Store (&html )
}
p .pushNowMx .Unlock ()
if !ok {
http .Error (w , "render timeout" , http .StatusServiceUnavailable )
return
}
w .Header ().Set ("Content-Type" , "text/html; charset=utf-8" )
w .Header ().Set ("Cache-Control" , "no-store" )
_, _ = w .Write ([]byte (html ))
}
func (p *Page [M ]) serveWs (w http .ResponseWriter , r *http .Request ) {
if p .Disposed .Load () {
http .Error (w , "page disposed" , http .StatusServiceUnavailable )
return
}
c , err := websocket .Accept (w , r , &websocket .AcceptOptions {
OriginPatterns : p .opts .OriginPatterns ,
})
if err != nil {
return
}
defer c .CloseNow ()
c .SetReadLimit (maxMsgSize )
p .clientsMx .Lock ()
p .clients [c ] = struct {}{}
p .clientsMx .Unlock ()
defer func () {
p .clientsMx .Lock ()
delete (p .clients , c )
p .clientsMx .Unlock ()
}()
if html , ok := p .currentHtml (); ok {
p .sendHtml (c , html )
}
for {
_ , data , err := c .Read (p .ctx )
if p .ctx .Err () != nil {
return
}
if err != nil {
if websocket .CloseStatus (err ) == -1 {
p .log .Debug ("livepage: read" , "err" , err )
}
return
}
var ev DomEvent
if err := json .Unmarshal (data , &ev ); err != nil {
continue
}
if ev .Type == "redraw" {
if html , ok := p .currentHtml (); ok {
p .sendHtml (c , html )
}
continue
}
ev .Event = am .CtxToEv (p .ctx )
p .dispatch (&ev )
}
}
func (p *Page [M ]) dispatch (ev *DomEvent ) {
ok := p .compo .Mach ().Eval (evalSource , func () {
if ev .Handler != "" {
p .handlersMx .RLock ()
handler := p .handlers [ev .Handler ]
if handler == nil {
handler = p .prevHandlers [ev .Handler ]
}
p .handlersMx .RUnlock ()
if handler != nil {
ctx := h .Context {}
if live , ok := any (p .compo ).(interface { LiveContext () h .Context }); ok {
ctx = live .LiveContext ()
}
data := map [string ]string {
"type" : ev .Type ,
"id" : ev .Id ,
"name" : ev .Name ,
"value" : ev .Value ,
"data" : ev .Data ,
"path" : ev .Path ,
"key" : ev .Key ,
"code" : ev .Code ,
"button" : strconv .Itoa (ev .Button ),
"clientX" : strconv .FormatFloat (ev .ClientX , 'f' , -1 , 64 ),
"clientY" : strconv .FormatFloat (ev .ClientY , 'f' , -1 , 64 ),
"deltaX" : strconv .FormatFloat (ev .DeltaX , 'f' , -1 , 64 ),
"deltaY" : strconv .FormatFloat (ev .DeltaY , 'f' , -1 , 64 ),
"scrollTop" : strconv .FormatFloat (ev .ScrollTop , 'f' , -1 , 64 ),
"scrollLeft" : strconv .FormatFloat (ev .ScrollLeft , 'f' , -1 , 64 ),
}
handler (ctx , h .Event {Data : data })
}
}
p .compo .Update ()
p .renderer .ConsumeAll ()
}, p .ctx )
if !ok {
return
}
p .Push ()
}
func ToRef [T h .UI ](target *T , elem T ) T {
*target = elem
return elem
}
func injectHead(doc , tags string ) string {
i := strings .Index (strings .ToLower (doc ), "</head>" )
if i < 0 {
return tags + doc
}
return doc [:i ] + tags + doc [i :]
}
func htmlHash(html string ) string {
h := fnv .New32a ()
_, _ = h .Write ([]byte (html ))
return strconv .FormatUint (uint64 (h .Sum32 ()), 16 )
}
func utf16Len(s string ) int {
n := 0
for _ , r := range s {
n += utf16 .RuneLen (r )
}
return n
}
The pages are generated with Golds v0.8.7 . (GOOS=linux GOARCH=amd64)
Golds is a Go 101 project developed by Tapir Liu .
PR and bug reports are welcome and can be submitted to the issue list .
Please follow @zigo_101 (reachable from the left QR code) to get the latest news of Golds .