Phoenix LiveView / Laravel LiveWire in Go

In Phoenix (Elixir) und Laravel (PHP) gibt es die Idee, Single-Page-Applications (SPAs) zu entwickeln, die komplett auf dem Server gerendert werden. Dadurch muss im Idealfall kein paralleles JavaScript-Projekt geschrieben werden oder auf komplexe Frameworks wie React oder Vue zurückgegriffen werden.

Um trotzdem SEO-optimiert zu sein, wird die Webseite einmal komplett auf dem Server gerendert. Der Browser öffnet einen WebSocket zum Server und empfängt darüber die Änderungen.

Die Implementierungen unterscheiden sich voneinander. Da PHP WebSockets nur umständlich unterstützt, wird hier auf Polling gesetzt und die Verbindung wird bei jedem Request neu aufgebaut. Um dabei den State zu erhalten, wird dieser bei jedem Request als JSON komplett übertragen und im Browser gespeichert.

WebBrowser, Websocket, Server Kommunikation

In Elixir wird für die WebSocket-Verbindung ein Prozess auf dem Server gestartet. Solange der WebSocket geöffnet ist, kann der Zustand auf dem Server gehalten werden und bei jedem Request nur die Änderungen übertragen werden. Elixir erkennt beim Kompilieren des Templates automatisch, welche Teile dynamisch sind und nummeriert sie durch. So können nur die Änderungen an den Client übertragen werden.

Es gibt auch Implementierungen in Go, jedoch sind die meisten unvollständig oder werden nicht mehr weiterentwickelt. Allerdings wird diese Implementierung höchstwahrscheinlich auch nicht vollständig sein. Mein Ziel ist es, das System zu verstehen, deshalb versuche ich, eine eigene Implementierung zu schreiben. Es ist wichtig zu beachten, dass das Errorhandling oft fehlen wird! Eine viel weiter entwickelte Implementierung ist jfyne / live.

Setup link

Ich werde meine Implementierung auf dem Fiber Framework aufbauen. Fiber ist ein schnelles Webframework für Go und hat eine Implementierung für Templates sowie WebSockets.

Komponenten link

Komponenten sind vergleichbar mit SingleFileComponents in Vue. Sie halten ihren Zustand isoliert, können Events empfangen und ihre eigene View rendern.

package liveview

type Component interface {
	ComponentName() string
	Mount(data []interface{}) error
	OnEvent(eventName string, payload map[string]interface{})
	Render(ctx *Ctx) string
}

Der ComponentName wird verwendet, um die Komponente in normalen statischen Templates verwenden zu können.

Mount wird aufgerufen, wenn die Komponente gestartet wird. Dies geschieht zweimal. Beim statischen Rendern der Webseite enthält data die im Template übergebenen Daten. Wird die Komponente ein zweites Mal über den Websocket gestartet, enthält data keine Daten! Alle public Variablen werden mit JSON initialisiert.

Für mein Beispiel habe ich eine einfache Komponente geschrieben, die einen Zähler darstellt. Zusätzlich hat die Komponente noch ein Formularfeld.

package main

import (
	"log/slog"

	"github.com/gofiber/fiber/v2"
	liveview "github.com/pkuebler/go-liveview"
)

type User struct {
    Name     string
    Password string
}

type CounterComponent struct {
    customLog *slog.Logger

    // Count and Name are public and will send to the client!
    Count int
    Name  string
}

func (c *CounterComponent) ComponentName() string {
    return "counter"
}

func (c *CounterComponent) Mount(data []interface{}) error {
    c.customLog.Info("CounterComponent mounted")

    if data == nil {
		return nil
	}

    // set the data from the template
    // set not the full User struct to avoid passwort leaks
    c.Name = data[0].(*User).Name
	return nil
}

In Fiber werden Templates über fiber.Ctx.Render() gerendert und als Response zurückgeschickt. Leider kann ich den Fiber Context im WebSocket nicht verwenden, da ich das Template über den WebSocket schicken muss. Um genauso flexibel zu sein, werde ich später einen eigenen Context verwenden.

func (c *CounterComponent) Render(ctx *liveview.Ctx) string {
    c.customLog.Info("Render CounterComponent")

    return ctx.Render("counter", fiber.Map{
		"Count": c.Count,
		"Name":  c.Name,
	})
}

Der Aufruf der Render-Funktion mit den zur Verfügung stehenden Variablen funktioniert weiterhin wie von Fiber gewohnt. Ein Beispiel für das Template:

<div>
    <h1>Counter</h1>
    <p>Count: <span id="count">{{ .Count }}</span></p>
    <a ghx-click="increment">Increment</a>
    <a ghx-click="decrement">Decrement</a>
    <div>
    {{ if gt .Count 10 }}
        <p>Count is greater than or equal to 10</p>
    {{ else }}
        <p>Count is less than 10</p>
    {{ end }}
    </div>
    <form ghx-change="change" ghx-submit="submit">
        <div>
            <input name="name" value="{{ .Name }}">
        </div>
        <input type="submit" value="Submit">
    </form>
</div>

Integration von LiveView-Komponenten in Go-Templates link

Wie bereits erwähnt, sollen die Komponenten in statischen Templates gerendert werden, aber auch später über WebSockets verwendet werden können. Dazu füge ich der Engine eine Funktion hinzu, die die Komponente in ein Template einfügt. Um die Engine dynamischer wechseln zu können, ruft diese Funktion nur den ComponentHandler des LiveViews auf.

package main

func main() {
    liveView := liveview.NewLiveView()

    // Go-Template Engine
    engine := html.New("./views", ".html")
    // Add a helper function to add a liveView component to the Template
    engine.AddFunc("liveComponent", func(componentName string, data ...interface{}) template.HTML {
        // return template string as template.HTML, so it will not be escaped by the template engine
		return template.HTML(liveView.ComponentHandler(componentName, data...))
	})

    // Set the Engine to liveView, so it can render the components
    liveView.SetViews(engine)

    log := slog.Default()
    // Register a function to create a new CounterComponent
	liveView.RegisterComponentFactory("counter", func() liveview.Component {
        // this allow to inject custom data into the component
        // for example a logger or a database connection
		return &CounterComponent{
			customLog: log,
		}
	})
}

Anschließend muss die eigentliche Fiber App gestartet werden. Hier wird die Engine als View Engine definiert und die normalen Router mit Controller angelegt. Um später den WebSocket JS Client auszuliefern, definiere ich noch einen Static File Router.

func main() {
    // ...

    app := fiber.New(fiber.Config{
        Views: engine,
    })

    // Serve JS Websocket Client
    app.Static("/", "./public")

    // Setup App Routes
    app.Get("/", func(c *fiber.Ctx) error {
        user := &User{
            Name: "Peter",
            Password: "SuperSecretPassword",
        }

        return c.Render("index", fiber.Map{
            "User": user,
        })
    })

    // Start App
    app.Listen(":3000")
}

Im HTML-Template kann die Komponente nun mit {{ liveComponent "counter" }} gerendert werden. Um weitere Daten an die Komponente zu übergeben, können diese als Parameter übergeben werden. Für den folgenden Code habe ich bereits zwei JavaScript-Dateien eingebunden. morphdom ist ein schneller DOM Diff Algorithmus und live.js wird später der WebSocket Client sein.

<html>
<body>
    {{ liveComponent "counter" .User }}
    <script src="https://cdn.jsdelivr.net/npm/morphdom@2.7.2/dist/morphdom-umd.min.js"></script>
    <script src="/live.js"></script>
</body>
</html>

Komponenten und Renderengine registrieren link

Zur Registrierung werden die übermittelten Daten einfach in einer Map gespeichert.

package liveview

type LiveView struct {
	views              fiber.Views
	componentFactories map[string]func() Component
}

func NewLiveView() *LiveView {
	return &LiveView{
		componentFactories: make(map[string]func() Component),
	}
}

func (l *LiveView) SetViews(views fiber.Views) {
	l.views = views
}

func (l *LiveView) RegisterComponentFactory(name string, factory func() Component) {
	l.componentFactories[name] = factory
}

Der ComponentHandler ruft die Render Funktion der Komponente auf und gibt das gerenderte Template zurück. Um die Komponenten später unterscheiden zu können, wird eine eindeutige ID generiert.

// ComponentHandler at template
func (l *LiveView) ComponentHandler(componentName string, data ...interface{}) string {
    // get the factory function for the component
	factory := l.componentFactories[componentName]

    // create a new component
	component := factory()

    // mount the component with the parameters from the template
	if err := component.Mount(data); err != nil {
		fmt.Println("Error mounting component", err)
		return ""
	}

    // call a internal render function, add a unique id to the component
	return l.render(uuid.New().String(), component)
}

Die interne Funktion render erzeugt den Context und ruft die Render-Funktion der Komponente auf. Anschließend wird im Template mit gohtml "golang.org/x/net/html" die ID und der Name der Komponente hinzugefügt.

Da die html Funktion beim Parsen leider immer ein html und ein body Element hinzufügt, muss das Root-Element der Komponente gefunden werden. Dann werden die ID und der Name der Komponente als Attribute hinzugefügt.

func (l *LiveView) render(id string, component Component) string {
	ctx := &Ctx{
		views: l.views,
	}
	output := component.Render(ctx)

    // add to root html element attrs
	doc, err := gohtml.Parse(strings.NewReader(output))
	if err != nil {
		return ""
	}

    // find root element (html adds a html and body element)
	var componentRoot *gohtml.Node
	var f func(*gohtml.Node)
	f = func(n *gohtml.Node) {
		if n.Type == gohtml.ElementNode && n.Data == "body" {
			componentRoot = n.FirstChild
			return
		}

		for c := n.FirstChild; c != nil; c = c.NextSibling {
			f(c)
		}
	}
	f(doc)

	if componentRoot.Type != gohtml.ElementNode {
		fmt.Println("Component allows only one root element")
		return ""
	}

	componentRoot.Attr = append(componentRoot.Attr, gohtml.Attribute{
		Namespace: "",
		Key:       "data-ghx-id",
		Val:       id,
	}, gohtml.Attribute{
		Namespace: "",
		Key:       "data-ghx-component",
		Val:       component.ComponentName(),
	})

	// render html back to string
	a := &strings.Builder{}
	gohtml.Render(a, componentRoot)
	return a.String()
}

Render Context link

Um in der Render Funktion der Komponente auf die Render Funktion der Engine zugreifen zu können, wird ein eigener Context erstellt. Der Context kann stark erweitert werden. Ich habe nur den nötigsten Code von Fiber.Ctx kopiert.

package liveview

import (
	"github.com/gofiber/fiber/v2"
	"github.com/valyala/bytebufferpool"
)

type Ctx struct {
	views fiber.Views
}

func (c *Ctx) Render(name string, bind interface{}, layouts ...string) string {
	// Get new buffer from pool
	buf := bytebufferpool.Get()
	defer bytebufferpool.Put(buf)

	// Initialize empty bind map if bind is nil
	if bind == nil {
		bind = make(fiber.Map)
	}

	c.views.Render(buf, name, bind, layouts...)

	return buf.String()
}

Gegebenenfalls wäre es sinnvoll, mehr Renderlogik in den Context zu verlagern, um möglichst lange mit dem ByteBuffer arbeiten zu können.

WebSocket link

Die eigentliche Magie findet im WebSocket statt. Der Client sendet Events an den Server, der Server ruft die OnEvent Funktion der Komponente auf und sendet das gerenderte Template zurück.

Das Austauschformat ist ein JSON-Array. Der erste Wert ist der Event-Name, der zweite Wert ist die Komponenten-ID. Die anderen Werte variieren je nach Ereignis.

["ghx-join", "uuid", "componentName", "initData as JWT"]

["ghx-click", "uuid", "eventName"]

["ghx-change", "uuid", "eventName", "formData"]

["ghx-submit", "uuid", "eventName", "formData"]

Das ghx-join Event wird aufgerufen, wenn der Client die Komponente zum ersten Mal lädt. Der Server gibt das gerenderte Template zurück. Der initData Wert ist ein JWT Token, der die Daten für die Komponente enthält, da der WebSocket keine Information darüber hat, woher die Komponente kommt.

// WebsocketHandler
func (l *LiveView) WebsocketHandler() func(ctx *fiber.Ctx) error {
	return websocket.New(func(c *websocket.Conn) {
        // new client connected
		componentsByID := make(map[string]Component)

		// read messages
		for {
            // wait for next message
			_, msg, err := c.ReadMessage()
			if err != nil {
				break
			}

			// parse message
			msgParts := []interface{}{}
			if err := json.Unmarshal(msg, &msgParts); err != nil {
				continue
			}

            eventType := msgParts[0].(string)
			switch msgParts[0] {
			case "ghx-join":
				ghxID := msgParts[1].(string)
				componentName := msgParts[2].(string)
				tokenString := msgParts[3].(string)

				// validate session
				token, err := jwt.ParseWithClaims(tokenString, &Session{}, func(token *jwt.Token) (interface{}, error) {
					return []byte("secret"), nil
				})

                // create new component by name
                factory := l.componentFactories[componentName]
				component := factory()

                // inject the component data from the jwt token
				if err := json.Unmarshal([]byte(token.Claims.(*Session).Data), component); err != nil {
					fmt.Println("Error unmarshalling session data", err)
					continue
				}

                // mount the component
				if err := component.Mount(nil); err != nil {
					fmt.Println("Error mounting component", err)
					continue
				}

				componentsByID[ghxID] = component

				// render component
				html := l.render(ghxID, component)
				b, _ := json.Marshal([]interface{}{"ghx-reply", ghxID, html})

                // send the rendered component back to the client
				c.WriteMessage(websocket.TextMessage, b)
            // ... other eventTypes
            }
        }
    })
}

Die anderen Eventtypen funktionieren ähnlich, benötigen aber das Token nicht mehr.

JavaScript Client link

Die JavaScript-Clients von Phoenix und Laravel sind sehr umfangreich. Mein Client ist sehr rudimentär und basiert wie der Phoenix Client auf der JavaScript Lib morphdom. Es gibt Ansätze hier AlpineJS zu verwenden. Für den Prototyp habe ich bereits das Index Template morphdom per CDN eingebunden.

Als erstes werden alle Komponenten anhand des Attributs data-ghx-id gesucht. Wenn keine Live Komponenten gefunden werden, wird auch kein WebSocket geöffnet.

(function () {
    // find all elements with data-ghx-id attribute
    var elements = document.querySelectorAll('[data-ghx-id]');
    if (elements.length == 0) {
        return;
    }

    // connect websocket
    const ws = new WebSocket('ws://localhost:3000/live/ws');
    ws.onmessage = function (event) {
        // ...
    };

    ws.onopen = function () {
        // ...
    }

    window.liveWS = ws;

    // initial event handlers
    elements.forEach(function (element) {
        // ...
    });
}());

Jede Komponente muss auf dem Server gestartet werden. Dazu wird für jede Komponente ein ghx-join Event an den Server gesendet.

    ws.onopen = function () {
        console.log('connected to websocket');
        elements.forEach(function (element) {
            var id = element.getAttribute('data-ghx-id');
            var component = element.getAttribute('data-ghx-component');
            var session = element.getAttribute('data-ghx-session');
            ws.send(JSON.stringify(['ghx-join', id, component, session]));
        })
    }

Da das gerenderte Template vom Server durch einen Morph eingefügt wird, werden nur die geänderten HTML-Elemente mit Listenern versehen. Daher werden zu Beginn für alle Elemente die Listener gesetzt.

    // initial event handlers
    elements.forEach(function (element) {
        var id = element.getAttribute('data-ghx-id');
        var component = element.getAttribute('data-ghx-component');
        console.log(id, component);

        // ghx-click
        var clickElements = element.querySelectorAll('[ghx-click]');
        clickElements.forEach(function (clickElement) {
            clickElement.addEventListener('click', onClick);
        });
    });

Server Side Rendering morphen link

Zuletzt wird das gerenderte Template vom Server mit dem Algorithmus morphdom in das bestehende DOM eingefügt. Ich habe Ansätze gesehen, die das mit AlpineJS.Morph umsetzen, das sollte auch funktionieren. Für meinen Anwendungsfall habe ich mich für morphdom entschieden.

Da JavaScript EventListener mehrfach gesetzt werden können und dann auch mehrere Events auslösen, muss darauf geachtet werden, dass die Listener nur einmal gesetzt werden. Daher werden die Listener für neue Elemente im morphdom Event onNodeAdded gesetzt und für entfernte Elemente im Event onBeforeNodeDiscarded entfernt.

    ws.onmessage = function (event) {
        // parse the message (json array)
        const data = JSON.parse(event.data);

        const action = data[0];
        const id = data[1];

        // find the component element by id
        var element = document.querySelector('[data-ghx-id="' + id + '"]');
        if (!element) {
            console.error('element not found', id);
            return
        }

        if (action === 'ghx-reply') {
            // convert the html string to a dom element
            var div = document.createElement('div');
            div.innerHTML = data[2];
            console.log('ghx-reply', div.firstChild);

            // morph the dom
            morphdom(
                element,
                div.firstChild,
                {
                    // add listener to new elements
                    onNodeAdded: function (node) {
                        console.log('node added', node);

                        // add ghx-click event handlers
                        var clickElements = node.querySelectorAll('[ghx-click]');
                        clickElements.forEach(function (clickElement) {
                            clickElement.addEventListener('click', onClick);
                        });
                    },
                    // remove listener from removed elements
                    onBeforeNodeDiscarded: function (node) {
                        console.log('node discarded', node);

                        // remove ghx-click event handlers
                        var clickElements = node.querySelectorAll('[ghx-click]');
                        clickElements.forEach(function (clickElement) {
                            clickElement.removeEventListener('click', onClick);
                        });
                    },
                }
            );
        }
    }

Zur Vervollständigung des Prototyps fehlt noch die Event-Handling für ghx-click.

// find closest parent with data-ghx-id or ghx-change
function findParentAttr(element, attrName) {
    while (element) {
        if (element.hasAttribute(attrName)) {
            return element.getAttribute(attrName);
        }
        element = element.parentElement;
    }
    return null;
}

function onClick(event) {
    var id = findParentAttr(event.target, 'data-ghx-id');
    var action = findParentAttr(event.target, 'ghx-click');
    console.log('click', id, action);
    window.liveWS.send(JSON.stringify(['ghx-click', id, action]));
}

Fazit link

Der Prototyp funktioniert und zeigt, dass es relativ einfach ist, LiveView Komponenten in Go zu implementieren. Es fehlen noch relativ viel Error Handling, Security Features wie CSRF Token und einige Performance Optimierungen. Dazu ist die größte Schwachstelle im deutschen Internet der WebSocket. Hier müssten noch einige Optimierungen für den Reconnect implementiert werden.

LiveView und LiveWire sind sehr interessante Konzepte, die es ermöglichen komplexe Webanwendungen ohne JavaScript zu schreiben. Der WebSocket benötigt eine stabilere Internetverbindung als der Laravel LiveWire Polling Ansatz. Beide Ansätze machen aber nur Spaß, wenn das UI wirklich schnell reagiert, da alles vom Server kommt. Ich würde ihn für Software empfehlen, die immer mit Internet auf dem Server arbeitet.