~damien/hotwire-go-poc

238ff56a6cfc647add8c07ae765396ccb32f09e4 — Damien Radtke 2 years ago
Initial commit
7 files changed, 238 insertions(+), 0 deletions(-)

A README.md
A go.mod
A go.sum
A main.go
A static/counter.tmpl
A static/index.html
A static/support.js
A  => README.md +5 -0
@@ 1,5 @@
This repository attempts to apply [Hotwire](https://hotwired.dev/) development concepts to Go. It
has many rough edges and is absolutely not ready for production use, but it does demonstrate the
fundamental concept: single-page web applications that keep the logic and rendering on the server.

<!-- vim: set tw=100: -->

A  => go.mod +8 -0
@@ 1,8 @@
module hotwire

go 1.16

require (
	github.com/gorilla/mux v1.8.0 // indirect
	github.com/gorilla/websocket v1.4.2 // indirect
)

A  => go.sum +4 -0
@@ 1,4 @@
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=

A  => main.go +179 -0
@@ 1,179 @@
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"html/template"
	"log"
	"net/http"
	"sync"

	"github.com/gorilla/mux"
	"github.com/gorilla/websocket"
)

const port = 8080

var buffers = sync.Pool{
	New: func() interface{} {
		return &bytes.Buffer{}
	},
}

var funcs = map[string]interface{}{
	// frame defines a new Hotwire frame by creating a <div> placeholder for its
	// contents, sourcing the support.js file for it, and performing an initial
	// render.
	"frame": func(frameID string) (template.HTML, error) {
		rendered, err := renderFrame(frameID)
		if err != nil {
			return template.HTML(""), fmt.Errorf("frame: %w", err)
		}
		return template.HTML(fmt.Sprintf(`
			<div data-frame-id="%s">%s</div>
			<script src="/static/support.js" data-frame-id="%s"></script>
		`, frameID, string(rendered), frameID)), nil
	},
}

// frameTemplates defines the templates for each frame.
var frameTemplates = map[string]*template.Template{
	"counter": template.Must(template.New("").ParseFiles("static/counter.tmpl")),
}

// frameStates defines the state for each frame. In this proof-of-concept,
// frame states are global, but in a more complex setup they should probably
// be scoped by user, session, etc.
var frameStates = map[string]ActionHandler{
	"counter": &Counter{},
}

type ActionHandler interface {
	// HandleAction handles actions sent from the client. It returns true if the
	// relevant frame needs to be rerendered, otherwise false.
	// In addition to the action name, it could theoretically accept additional
	// parameters, but for now we're keeping it simple.
	HandleAction(string) bool
}

// Counter defines the state and handler for the "counter" frame.
type Counter struct {
	Count int
}

func (c *Counter) HandleAction(action string) bool {
	switch action {
	case "increment":
		c.Count += 1
		return true

	case "decrement":
		c.Count -= 1
		return true

	default:
		return false
	}
}

// renderFrame executes the template for the given frame using its current state.
func renderFrame(frameID string) ([]byte, error) {
	buf := buffers.Get().(*bytes.Buffer)
	defer buffers.Put(buf)
	buf.Reset()

	if err := frameTemplates[frameID].ExecuteTemplate(buf, frameID+".tmpl", frameStates[frameID]); err != nil {
		return nil, fmt.Errorf("renderFrame: error executing template: %w", err)
	}
	return buf.Bytes(), nil
}

// hotwire defines a handler for the WebSocket connection. The support.js file
// will use this endpoint to open a connection over which actions will be sent
// to the server, and rendered HTML sent to the client.
func hotwire() http.HandlerFunc {
	var upgrader = websocket.Upgrader{
		ReadBufferSize:  1024,
		WriteBufferSize: 1024,
	}

	return func(w http.ResponseWriter, r *http.Request) {
		conn, err := upgrader.Upgrade(w, r, nil)
		if err != nil {
			log.Printf("hotwire: error upgrading to websocket: %s", err)
			return
		}

		var frameID string

		render := func() error {
			rendered, err := renderFrame(frameID)
			if err != nil {
				return fmt.Errorf("template execution error: %w", err)
			}

			if err = conn.WriteMessage(websocket.TextMessage, rendered); err != nil {
				return fmt.Errorf("connection write error: %w", err)
			}

			return nil
		}

		for {
			_, p, err := conn.ReadMessage()
			if err != nil {
				log.Printf("hotwire: error: %s", err)
				return
			}

			var msg = struct {
				Action string          `json:"action"`
				Data   json.RawMessage `json:"data"`
			}{}
			if err := json.Unmarshal(p, &msg); err != nil {
				log.Printf("hotwire: json error: %s", err)
				return
			}

			if msg.Action == "init" {
				var data = struct {
					Frame string `json:"frame"`
				}{}
				if err = json.Unmarshal(msg.Data, &data); err != nil {
					log.Printf("hotwire: json error: init: %s", err)
					return
				}
				frameID = data.Frame
			} else {
				if frameStates[frameID].HandleAction(msg.Action) {
					if err = render(); err != nil {
						log.Printf("hotwire: render: %s", err)
						return
					}
				}
			}
		}
	}
}

// index defines a simple handler that renders our main page.
func index() http.HandlerFunc {
	t := template.Must(template.New("").Funcs(funcs).ParseFiles("static/index.html"))
	return func(w http.ResponseWriter, r *http.Request) {
		if err := t.ExecuteTemplate(w, "index.html", nil); err != nil {
			log.Printf("index: error executing template: %s", err)
		}
	}
}

func main() {
	router := mux.NewRouter()
	router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
	router.Path("/hotwire").HandlerFunc(hotwire())
	router.Path("/").HandlerFunc(index())
	log.Printf("listening on port %d...", port)
	if err := http.ListenAndServe(fmt.Sprintf(":%d", port), router); err != nil {
		log.Printf("error: %s", err)
	}
}

A  => static/counter.tmpl +3 -0
@@ 1,3 @@
<p>{{.Count}}</p>
<button data-frame-action="increment">Increment</button>
<button data-frame-action="decrement">Decrement</button>

A  => static/index.html +6 -0
@@ 1,6 @@
<html>
<body>
  <h3>Simple Counter</h3>
  {{frame `counter`}}
</body>
</html>

A  => static/support.js +33 -0
@@ 1,33 @@
// These brackets are an attempt to keep everything out of the global scope.
{
  var frameID = document.currentScript.getAttribute("data-frame-id");
  console.log("==> Initializing frame: " + frameID);
  var elem = document.querySelector('div[data-frame-id="' + frameID + '"]');

  // TODO: don't hardcode the URL
  var socket = new WebSocket("ws://localhost:8080/hotwire");

  socket.onopen = function(event) {
    socket.send(JSON.stringify({
      "action": "init",
      "data": {
        "frame": frameID,
      }
    }));
  };

  socket.onmessage = function(event) {
    elem.innerHTML = event.data;
  };

  document.addEventListener('click', function(event) {
    var action = event.target.getAttribute("data-frame-action");
    if (!action) {
      return;
    }
    socket.send(JSON.stringify({
      "action": action
      // TODO: other params could be added as well
    }));
  });
}