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, - ` - - -
- `, - ) - - fmt.Fprintf( - w, - ``, - base64.StdEncoding.EncodeToString(webp), - ) - - fmt.Fprintf( - w, - ``, - host, - port, - ) - - 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) + } + } +}