diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index c3c65df994..654981f5c7 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -20,6 +20,8 @@ jobs:
steps:
- name: Install Go
uses: actions/setup-go@v2
+ with:
+ go-version: '1.16.12'
- name: Checkout code
uses: actions/checkout@v2
diff --git a/go.mod b/go.mod
index 6ddd130456..510c468456 100644
--- a/go.mod
+++ b/go.mod
@@ -11,6 +11,7 @@ require (
github.com/fsnotify/fsnotify v1.5.1
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
+ github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.4.2
github.com/harukasan/go-libwebp v0.0.0-20190703060927-68562c9c99af
github.com/lucasb-eyer/go-colorful v1.2.0
@@ -25,5 +26,6 @@ require (
go.starlark.net v0.0.0-20211203141949-70c0e40ae128
golang.org/x/image v0.0.0-20211028202545-6944b10bf410
golang.org/x/net v0.0.0-20210825183410-e898025ed96a // indirect
+ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/text v0.3.7 // indirect
)
diff --git a/go.sum b/go.sum
index 49f645c39e..21fce958c9 100644
--- a/go.sum
+++ b/go.sum
@@ -228,6 +228,8 @@ github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5m
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
+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.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
@@ -601,6 +603,7 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
diff --git a/serve.go b/serve.go
index 24757949b5..7c0cc6f88e 100644
--- a/serve.go
+++ b/serve.go
@@ -1,8 +1,6 @@
package main
import (
- "log"
-
"github.com/spf13/cobra"
"tidbyt.dev/pixlet/server"
@@ -25,10 +23,13 @@ var serveCmd = &cobra.Command{
Use: "serve [script]",
Short: "Serves a starlark render script over HTTP.",
Args: cobra.ExactArgs(1),
- Run: serve,
+ RunE: serve,
}
-func serve(cmd *cobra.Command, args []string) {
- s := server.NewServer(host, port, watch, args[0])
- log.Fatal(s.Run())
+func serve(cmd *cobra.Command, args []string) error {
+ s, err := server.NewServer(host, port, watch, args[0])
+ if err != nil {
+ return err
+ }
+ return s.Run()
}
diff --git a/server/browser/browser.go b/server/browser/browser.go
new file mode 100644
index 0000000000..386de8488e
--- /dev/null
+++ b/server/browser/browser.go
@@ -0,0 +1,151 @@
+// Package browser provides the ability to send WebP images to a browser over
+// websockets.
+package browser
+
+import (
+ _ "embed"
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+
+ "github.com/gorilla/mux"
+ "github.com/gorilla/websocket"
+ "golang.org/x/sync/errgroup"
+ "tidbyt.dev/pixlet/server/fanout"
+ "tidbyt.dev/pixlet/server/loader"
+)
+
+// Browser provides a structure for serving WebP images over websockets to
+// a web browser.
+type Browser struct {
+ addr string // The address to listen on.
+ title string // The title of the HTML document.
+ updateChan chan string // A channel of base64 encoded WebP images.
+ watch bool
+ fo *fanout.Fanout
+ r *mux.Router
+ tmpl *template.Template
+ loader *loader.Loader
+}
+
+//go:embed preview-mask.png
+var previewMask []byte
+
+//go:embed favicon.png
+var favicon []byte
+
+//go:embed preview.html
+var previewHTML string
+
+// previewData is used to populate the HTML template.
+type previewData struct {
+ Title string
+ WebP string
+ Watch bool
+}
+
+// NewBrowser sets up a browser structure. Call Run() to kick off the main loops.
+func NewBrowser(addr string, title string, watch bool, updateChan chan string, l *loader.Loader) (*Browser, error) {
+ tmpl, err := template.New("preview").Parse(previewHTML)
+ if err != nil {
+ return nil, err
+ }
+
+ b := &Browser{
+ updateChan: updateChan,
+ addr: addr,
+ fo: fanout.NewFanout(),
+ tmpl: tmpl,
+ title: title,
+ loader: l,
+ watch: watch,
+ }
+
+ r := mux.NewRouter()
+ r.HandleFunc("/", b.rootHandler)
+ r.HandleFunc("/ws", b.websocketHandler)
+ r.HandleFunc("/favicon.png", b.faviconHandler)
+ r.HandleFunc("/preview-mask.png", b.previewMaskHandler)
+ b.r = r
+
+ return b, nil
+}
+
+// Run starts the server process and runs forever in a blocking fashion. The
+// main routines include an update watcher to process incomming changes to the
+// webp and running the http handlers.
+func (b *Browser) Run() error {
+ defer b.fo.Quit()
+
+ g := errgroup.Group{}
+ g.Go(b.updateWatcher)
+ g.Go(b.serveHTTP)
+
+ return g.Wait()
+}
+
+func (b *Browser) serveHTTP() error {
+ log.Printf("listening at http://%s\n", b.addr)
+ return http.ListenAndServe(b.addr, b.r)
+}
+
+func (b *Browser) faviconHandler(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "image/png")
+ w.Write(favicon)
+}
+
+func (b *Browser) previewMaskHandler(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "image/png")
+ w.Write(previewMask)
+}
+
+func (b *Browser) websocketHandler(w http.ResponseWriter, r *http.Request) {
+ if !b.watch {
+ return
+ }
+
+ var upgrader = websocket.Upgrader{
+ ReadBufferSize: 1024,
+ WriteBufferSize: 1024,
+ }
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ log.Printf("error establishing a new connection %v\n", err)
+ return
+ }
+
+ b.fo.NewClient(conn)
+}
+
+func (b *Browser) updateWatcher() error {
+ for {
+ select {
+ case webp := <-b.updateChan:
+ b.fo.Broadcast(webp)
+ }
+ }
+}
+
+func (b *Browser) rootHandler(w http.ResponseWriter, r *http.Request) {
+ config := make(map[string]string)
+ for k, vals := range r.URL.Query() {
+ config[k] = vals[0]
+ }
+
+ webp, err := b.loader.LoadApplet(config)
+ if err != nil {
+ w.WriteHeader(500)
+ fmt.Fprintln(w, err)
+ return
+ }
+
+ data := previewData{
+ Title: b.title,
+ Watch: b.watch,
+ WebP: webp,
+ }
+
+ w.Header().Set("Content-Type", "text/html")
+ b.tmpl.Execute(w, data)
+}
diff --git a/server/browser/favicon.png b/server/browser/favicon.png
new file mode 100644
index 0000000000..189f47ca23
Binary files /dev/null and b/server/browser/favicon.png differ
diff --git a/server/browser/preview-mask.png b/server/browser/preview-mask.png
new file mode 100644
index 0000000000..cb91358760
Binary files /dev/null and b/server/browser/preview-mask.png differ
diff --git a/server/browser/preview.html b/server/browser/preview.html
new file mode 100644
index 0000000000..79582dddfa
--- /dev/null
+++ b/server/browser/preview.html
@@ -0,0 +1,95 @@
+
+
+
+
+ {{ .Title }}
+
+
+
+
+
+
+
+
+
+ {{ if .Watch }}
+
+ {{ end }}
+
+
+
\ No newline at end of file
diff --git a/server/fanout/client.go b/server/fanout/client.go
new file mode 100644
index 0000000000..a2ba810124
--- /dev/null
+++ b/server/fanout/client.go
@@ -0,0 +1,111 @@
+package fanout
+
+import (
+ "time"
+
+ "github.com/gorilla/websocket"
+)
+
+const (
+ // Time allowed to write a message to the peer.
+ writeWait = 10 * time.Second
+
+ // Time allowed to read the next pong message from the peer.
+ pongWait = 60 * time.Second
+
+ // Send pings to peer with this period. Must be less than pongWait.
+ pingPeriod = (pongWait * 9) / 10
+
+ // Maximum message size allowed from peer.
+ maxMessageSize = 512
+
+ // The size of the go channels for this client.
+ channelSize = 10
+)
+
+// Client provides a structure for incomming websocket requests so they can be
+// tracked by a Fanout.
+type Client struct {
+ fo *Fanout
+ conn *websocket.Conn
+ send chan string
+ quit chan bool
+}
+
+// NewClient instantiates a client with a websocket connection. It spwans off
+// go routines to send data to the client and send pings to ensure the client
+// is still alive. We're passing a Fanout here so the client can unregister
+// itself on error.
+func (f *Fanout) NewClient(conn *websocket.Conn) *Client {
+ c := &Client{
+ fo: f,
+ conn: conn,
+ send: make(chan string, channelSize),
+ quit: make(chan bool, 1),
+ }
+
+ f.RegisterClient(c)
+
+ go c.writer()
+ go c.reader()
+
+ return c
+}
+
+// Send is used to send a webp message to the client.
+func (c *Client) Send(webp string) {
+ c.send <- webp
+}
+
+// Quit will close the connection and unregiseter it from the Fanout.
+func (c *Client) Quit() {
+ c.fo.UnregisterClient(c)
+ c.quit <- true
+ c.conn.Close()
+}
+
+// reader reads pong messages off of the connection. Once it recieves a message,
+// the deadline is updated for when the next message must be recieved. If we
+// don't get a message within the deadline, this method calls Quit to clean up
+// the client.
+func (c *Client) reader() {
+ c.conn.SetReadLimit(maxMessageSize)
+ c.conn.SetReadDeadline(time.Now().Add(pongWait))
+ c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })
+
+ for {
+ _, _, err := c.conn.ReadMessage()
+ if err != nil {
+ c.Quit()
+ break
+ }
+ }
+}
+
+// writer writes webp events over the socket when it recieves messages via
+// Send(). It also sends pings to ensure the connection stays alive.
+func (c *Client) writer() {
+ ticker := time.NewTicker(pingPeriod)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-c.quit:
+ return
+ case webp := <-c.send:
+ event := WebsocketEvent{
+ Type: EventTypeWebP,
+ Message: webp,
+ }
+ c.conn.SetWriteDeadline(time.Now().Add(writeWait))
+ if err := c.conn.WriteJSON(event); err != nil {
+ c.Quit()
+ }
+ case <-ticker.C:
+ c.conn.SetWriteDeadline(time.Now().Add(writeWait))
+ if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
+ c.Quit()
+ }
+ }
+ }
+}
diff --git a/server/fanout/event.go b/server/fanout/event.go
new file mode 100644
index 0000000000..41e5086892
--- /dev/null
+++ b/server/fanout/event.go
@@ -0,0 +1,16 @@
+package fanout
+
+const (
+ // EventTypeWebP is used to signal what type of message we are sending over
+ // the socket.
+ EventTypeWebP = "webp"
+)
+
+// WebsocketEvent is a structure used to send messages over the socket.
+type WebsocketEvent struct {
+ // Message is the contents of the message. This is a webp, base64 encoded.
+ Message string `json:"message"`
+
+ // Type is the type of message we are sending over the socket.
+ Type string `json:"type"`
+}
diff --git a/server/fanout/fanout.go b/server/fanout/fanout.go
new file mode 100644
index 0000000000..d0c48c5251
--- /dev/null
+++ b/server/fanout/fanout.go
@@ -0,0 +1,70 @@
+package fanout
+
+// Fanout provides a structure for broadcasting messages to registered clients
+// when an update comes in on a go channel.
+type Fanout struct {
+ broadcast chan string
+ quit chan bool
+ register chan *Client
+ unregister chan *Client
+}
+
+// NewFanout creates a new Fanout structure and runs the main loop.
+func NewFanout() *Fanout {
+ fo := &Fanout{
+ broadcast: make(chan string, channelSize),
+ register: make(chan *Client, channelSize),
+ unregister: make(chan *Client, channelSize),
+ quit: make(chan bool, 1),
+ }
+
+ go fo.run()
+
+ return fo
+}
+
+// Broadcast sends a message to all registered clients.
+func (fo *Fanout) Broadcast(msg string) {
+ fo.broadcast <- msg
+}
+
+// RegisterClient registers a client to include in broadcasts.
+func (fo *Fanout) RegisterClient(c *Client) {
+ fo.register <- c
+}
+
+// UnregisterClient removes it from the broadcast.
+func (fo *Fanout) UnregisterClient(c *Client) {
+ fo.unregister <- c
+}
+
+// Quit stops broadcasting messages over the channel.
+func (fo *Fanout) Quit() {
+ fo.quit <- true
+}
+
+// run is the main loop. It provides a mechanism to register/unregister clients
+// and will broadcast messages as they come in.
+func (fo *Fanout) run() {
+ clients := map[*Client]bool{}
+
+ for {
+ select {
+ case <-fo.quit:
+ for client := range clients {
+ client.Quit()
+ }
+ case c := <-fo.register:
+ clients[c] = true
+ case c := <-fo.unregister:
+ if _, ok := clients[c]; ok {
+ delete(clients, c)
+ c.Quit()
+ }
+ case broadcast := <-fo.broadcast:
+ for client := range clients {
+ client.Send(broadcast)
+ }
+ }
+ }
+}
diff --git a/server/loader/loader.go b/server/loader/loader.go
new file mode 100644
index 0000000000..40b68dc6dc
--- /dev/null
+++ b/server/loader/loader.go
@@ -0,0 +1,133 @@
+// Package loader provides primitives to load an applet both when the underlying
+// file changes and on demand when an update is requested.
+package loader
+
+import (
+ "encoding/base64"
+ "fmt"
+ "io/ioutil"
+ "log"
+
+ "tidbyt.dev/pixlet/encode"
+ "tidbyt.dev/pixlet/runtime"
+)
+
+// Loader is a structure to provide applet loading when a file changes or on
+// demand.
+type Loader struct {
+ filename string
+ fileChanges chan bool
+ watch bool
+ applet runtime.Applet
+ configChanges chan map[string]string
+ requestedChanges chan bool
+ updatesChan chan string
+ resultsChan chan string
+}
+
+// NewLoader instantiates a new loader structure. The loader will read off of
+// fileChanges channel and write updates to the updatesChan. Updates are base64
+// encoded WebP strings. If watch is enabled, both file changes and on demand
+// requests will send updates over the updatesChan.
+func NewLoader(filename string, watch bool, fileChanges chan bool, updatesChan chan string) *Loader {
+ l := &Loader{
+ filename: filename,
+ fileChanges: fileChanges,
+ watch: watch,
+ applet: runtime.Applet{},
+ updatesChan: updatesChan,
+ configChanges: make(chan map[string]string, 100),
+ requestedChanges: make(chan bool, 100),
+ resultsChan: make(chan string, 100),
+ }
+
+ if !l.watch {
+ loadScript(&l.applet, l.filename)
+ }
+
+ return l
+}
+
+// Run executes the main loop. If there are config changes, those are recorded.
+// If there is an on-demand request, it's processed and sent back to the caller
+// and sent out as an update. If there is a file change, we update the applet
+// and send out the update over the updatesChan.
+func (l *Loader) Run() error {
+ config := make(map[string]string)
+
+ for {
+ select {
+ case c := <-l.configChanges:
+ config = c
+ case <-l.requestedChanges:
+ webp, err := l.loadApplet(config)
+ if err != nil {
+ log.Printf("error loading applet: %v", err)
+ l.resultsChan <- ""
+ continue
+ }
+ l.updatesChan <- webp
+ l.resultsChan <- webp
+ case <-l.fileChanges:
+ log.Printf("detected updates for %s, reloading\n", l.filename)
+
+ webp, err := l.loadApplet(config)
+ if err != nil {
+ log.Printf("error reloading applet: %v", err)
+ continue
+ }
+ l.updatesChan <- webp
+ }
+ }
+}
+
+// LoadApplet loads the applet on demand.
+//
+// TODO: This method is thread safe, but has a pretty glaring race condition. If
+// two callers request an update at the same time, they have the potential to
+// get each others update. At the time of writing, this method is only called
+// when you refresh a webpage during app development - so it doesn't seem likely
+// that it's going to cause issues in the short term.
+func (l *Loader) LoadApplet(config map[string]string) (string, error) {
+ l.configChanges <- config
+ l.requestedChanges <- true
+ result := <-l.resultsChan
+ if result == "" {
+ return "", fmt.Errorf("encountered and error loading applet")
+ }
+ return result, nil
+}
+
+func (l *Loader) loadApplet(config map[string]string) (string, error) {
+ if l.watch {
+ loadScript(&l.applet, l.filename)
+ }
+
+ roots, err := l.applet.Run(config)
+ if err != nil {
+ return "", fmt.Errorf("error running script: %w", err)
+ }
+
+ webp, err := encode.ScreensFromRoots(roots).EncodeWebP()
+ if err != nil {
+ return "", fmt.Errorf("error rendering: %w", err)
+ }
+
+ return base64.StdEncoding.EncodeToString(webp), nil
+}
+
+func loadScript(applet *runtime.Applet, filename string) error {
+ src, err := ioutil.ReadFile(filename)
+ if err != nil {
+ return fmt.Errorf("failed to read file %s: %w", filename, err)
+ }
+
+ runtime.InitCache(runtime.NewInMemoryCache())
+
+ err = applet.Load(filename, src, nil)
+ if err != nil {
+ return fmt.Errorf("failed to load applet: %w", err)
+ }
+
+ return nil
+}
diff --git a/server/server.go b/server/server.go
index 034ffc42ba..e5551f6504 100644
--- a/server/server.go
+++ b/server/server.go
@@ -1,232 +1,54 @@
package server
import (
- "encoding/base64"
"fmt"
- "io"
- "io/ioutil"
- "log"
- "net/http"
- "os"
- "sync"
- "github.com/fsnotify/fsnotify"
- "github.com/gorilla/websocket"
- "tidbyt.dev/pixlet/encode"
- "tidbyt.dev/pixlet/runtime"
+ "golang.org/x/sync/errgroup"
+ "tidbyt.dev/pixlet/server/browser"
+ "tidbyt.dev/pixlet/server/loader"
+ "tidbyt.dev/pixlet/server/watcher"
)
// Server provides functionality to serve Starlark over HTTP. It has
// functionality to watch a file and hot reload the browser on changes.
type Server struct {
- host string
- port int
- watch bool
- filename string
- mutex sync.RWMutex
- applet runtime.Applet
-}
-
-type websocketEvent struct {
- Type string `json:"type"`
+ watcher *watcher.Watcher
+ browser *browser.Browser
+ loader *loader.Loader
+ watch bool
}
// NewServer creates a new server initialized with the applet.
-func NewServer(host string, port int, watch bool, filename string) *Server {
- applet := runtime.Applet{}
+func NewServer(host string, port int, watch bool, filename string) (*Server, error) {
+ fileChanges := make(chan bool, 100)
+ w := watcher.NewWatcher(filename, fileChanges)
- return &Server{
- host: host,
- port: port,
- watch: watch,
- filename: filename,
- mutex: sync.RWMutex{},
- applet: applet,
- }
-}
+ updatesChan := make(chan string, 100)
+ l := loader.NewLoader(filename, watch, fileChanges, updatesChan)
-// Run serves the http server and runs forever in a blocking fashion.
-func (s *Server) Run() error {
- err := loadScript(&s.applet, s.filename)
+ addr := fmt.Sprintf("%s:%d", host, port)
+ b, err := browser.NewBrowser(addr, filename, watch, updatesChan, l)
if err != nil {
- return err
- }
- log.Println("loaded", s.filename)
-
- http.HandleFunc("/", s.serveRoot)
- http.HandleFunc("/favicon.ico", s.serveFavicon)
- http.HandleFunc("/ws", s.serveWebsocket)
-
- log.Printf("listening at http://%s:%d\n", s.host, s.port)
- return http.ListenAndServe(fmt.Sprintf("%s:%d", s.host, s.port), nil)
-
-}
-func (s *Server) serveFavicon(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(404)
-}
-
-func (s *Server) serveWebsocket(w http.ResponseWriter, r *http.Request) {
- if !s.watch {
- return
+ return nil, err
}
- var upgrader = websocket.Upgrader{
- ReadBufferSize: 1024,
- WriteBufferSize: 1024,
- }
- conn, err := upgrader.Upgrade(w, r, nil)
- if err != nil {
- log.Printf("error establishing a new connection %v\n", err)
- }
-
- go s.fileWatcher(conn)
-}
-
-func (s *Server) serveRoot(w http.ResponseWriter, r *http.Request) {
- config := make(map[string]string)
- for k, vals := range r.URL.Query() {
- config[k] = vals[0]
- }
-
- s.mutex.RLock()
- defer s.mutex.RUnlock()
-
- roots, err := s.applet.Run(config)
- if err != nil {
- log.Printf("Error running script: %s\n", err)
- return
- }
-
- webp, err := encode.ScreensFromRoots(roots).EncodeWebP()
- if err != nil {
- fmt.Printf("Error rendering: %s\n", err)
- return
- }
-
- w.Header().Set("Content-Type", "text/html")
- s.writePreviewHTML(w, s.host, s.port, webp)
-}
-
-func (s *Server) writePreviewHTML(w io.Writer, host string, port int, webp []byte) {
- fmt.Fprintln(
- w,
- `
-
-
-
`)
-}
-
-func (s *Server) fileWatcher(conn *websocket.Conn) {
- watcher, err := fsnotify.NewWatcher()
- if err != nil {
- log.Printf("error watching for changes: %v\n", err)
- os.Exit(1)
- }
-
- watcher.Add(s.filename)
-
- for {
- event, ok := <-watcher.Events
- if !ok {
- break
- }
- if (event.Op & fsnotify.Rename) != 0 {
- // When Vim saves a file, we get a Rename event followed
- // by silence. Re-adding allows us to capture future
- // events.
- watcher.Remove(event.Name)
- watcher.Add(s.filename)
- } else if (event.Op & (fsnotify.Write | fsnotify.Chmod)) != 0 {
- log.Printf("detected updates for %s, reloading\n", s.filename)
-
- // Reloading on Write is sufficient for most editors,
- // but with Vim we only get Chmod. No clue why.
- s.mutex.Lock()
- err := loadScript(&s.applet, s.filename)
- s.mutex.Unlock()
- if err != nil {
- log.Printf("error on reload: %v\n", err)
- continue
- }
-
- // Send update on websocket. If there is an error, break out of the
- // loop. When the browser reloads, it will recreate it's connection
- // and this goroutine will spawn again.
- err = conn.WriteJSON(websocketEvent{Type: "update"})
- if err != nil {
- break
- }
- }
- }
+ return &Server{
+ watcher: w,
+ browser: b,
+ loader: l,
+ watch: watch,
+ }, nil
}
-func loadScript(applet *runtime.Applet, filename string) error {
- src, err := ioutil.ReadFile(filename)
- if err != nil {
- return fmt.Errorf("failed to read file %s: %v", filename, err)
- }
-
- runtime.InitCache(runtime.NewInMemoryCache())
+// Run serves the http server and runs forever in a blocking fashion.
+func (s *Server) Run() error {
+ g := errgroup.Group{}
- err = applet.Load(filename, src, nil)
- if err != nil {
- return fmt.Errorf("failed to load applet: %v", err)
+ g.Go(s.loader.Run)
+ g.Go(s.browser.Run)
+ if s.watch {
+ g.Go(s.watcher.Run)
}
- return nil
+ return g.Wait()
}
diff --git a/server/watcher/watcher.go b/server/watcher/watcher.go
new file mode 100644
index 0000000000..92ac3530f1
--- /dev/null
+++ b/server/watcher/watcher.go
@@ -0,0 +1,63 @@
+// Package watcher provides a simple mechanism to watch a file for changes.
+package watcher
+
+import (
+ "fmt"
+ "path/filepath"
+
+ "github.com/fsnotify/fsnotify"
+)
+
+// Watcher is a structure to watch a file for changes and notify a channel.
+type Watcher struct {
+ filename string
+ fileChanges chan bool
+}
+
+// NewWatcher instantiates a new watcher with the provided filename and changes
+// channel.
+func NewWatcher(filename string, fileChanges chan bool) *Watcher {
+ return &Watcher{
+ filename: filename,
+ fileChanges: fileChanges,
+ }
+}
+
+// Run starts the file watcher in a blocking fashion. This watches an entire
+// directory and only notifies the channel when the specified file is changed.
+// If there is an error, it's returned. It's up to the caller to respawn the
+// watcher if it's desireable to keep watching.
+//
+// The reason it watches a directory is becausde some editers like VIM write
+// to a swap file and recreate the original file. So we can't simply watch the
+// original file, we have to watch the directory. This is also why we check both
+// the WRITE and CREATE events since VIM will write to a swap and then create
+// the file on save. VSCode does a WRITE and then a CHMOD, so tracking WRITE
+// catches the changes for VSCode exactly once.
+func (w *Watcher) Run() error {
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ return fmt.Errorf("error watching for changes: %w", err)
+ }
+ defer watcher.Close()
+
+ watcher.Add(filepath.Dir(w.filename))
+
+ for {
+ select {
+ case event, ok := <-watcher.Events:
+ if !ok {
+ return fmt.Errorf("something is weird with the file watcher")
+ }
+ if event.Name == w.filename && (event.Op&(fsnotify.Write|fsnotify.Create)) != 0 {
+ w.fileChanges <- true
+ }
+
+ case err, ok := <-watcher.Errors:
+ if !ok {
+ return fmt.Errorf("something is weird with the file watcher around error handling")
+ }
+ return fmt.Errorf("error in file watcher: %w", err)
+ }
+ }
+}