~damien/hotwire-go-poc

a599ce0929f09836279b03b658493fa2c4997459 — Damien Radtke 2 years ago 238ff56 master
Some minor refactoring
2 files changed, 61 insertions(+), 41 deletions(-)

M main.go
R static/{counter.tmpl => frames/counter.html}
M main.go => main.go +61 -41
@@ 5,8 5,11 @@ import (
	"encoding/json"
	"fmt"
	"html/template"
	"io/fs"
	"log"
	"net/http"
	"os"
	"strings"
	"sync"

	"github.com/gorilla/mux"


@@ 21,35 24,7 @@ var buffers = sync.Pool{
	},
}

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 {
type FrameState 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


@@ 77,22 52,45 @@ func (c *Counter) HandleAction(action string) bool {
	}
}

// renderFrame executes the template for the given frame using its current state.
func renderFrame(frameID string) ([]byte, error) {
type App struct {
	FrameTemplates *template.Template
	FrameStates    map[string]FrameState
}

func (a App) TemplateFuncs() map[string]interface{} {
	return 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 := a.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
		},
	}
}

// RenderFrame executes the template for the given frame using its current state.
func (a App) 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 {
	if err := a.FrameTemplates.ExecuteTemplate(buf, frameID+".html", a.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
// 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 {
func (a App) Hotwire() http.HandlerFunc {
	var upgrader = websocket.Upgrader{
		ReadBufferSize:  1024,
		WriteBufferSize: 1024,


@@ 108,7 106,7 @@ func hotwire() http.HandlerFunc {
		var frameID string

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


@@ 146,7 144,7 @@ func hotwire() http.HandlerFunc {
				}
				frameID = data.Frame
			} else {
				if frameStates[frameID].HandleAction(msg.Action) {
				if a.FrameStates[frameID].HandleAction(msg.Action) {
					if err = render(); err != nil {
						log.Printf("hotwire: render: %s", err)
						return


@@ 157,9 155,9 @@ func hotwire() http.HandlerFunc {
	}
}

// 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"))
// Index defines a simple handler that renders our main page.
func (a App) Index() http.HandlerFunc {
	t := template.Must(template.New("").Funcs(a.TemplateFuncs()).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)


@@ 167,11 165,33 @@ func index() http.HandlerFunc {
	}
}

// ParseTemplates parses all of the HTML files it finds within the given filesystem.
func ParseTemplates(filesystem fs.FS) (*template.Template, error) {
	patterns := make([]string, 0)
	fs.WalkDir(filesystem, ".", func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if !d.IsDir() && strings.HasSuffix(path, ".html") {
			patterns = append(patterns, path)
		}
		return nil
	})
	return template.New("").ParseFS(filesystem, patterns...)
}

func main() {
	var app = App{
		FrameTemplates: template.Must(ParseTemplates(os.DirFS("static/frames"))),
		FrameStates: map[string]FrameState{
			"counter": &Counter{},
		},
	}

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

R static/counter.tmpl => static/frames/counter.html +0 -0