├── .gitignore ├── LICENSE ├── README.md ├── example ├── go.mod ├── go.sum ├── main.go └── ui │ ├── index.html │ └── static │ └── index.css ├── go.mod ├── go.sum ├── go.work ├── go.work.sum ├── reload.go ├── watch.go └── wrap_writer.go /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Aaro Luomanen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Reload 2 | 3 | Reload is a Go package, which enables "soft reloading" of web server assets and templates, reloading the browser instantly via Websockets. The strength of Reload lies in it's simple API and easy integration to any Go projects. 4 | 5 | ## Installation 6 | 7 | `go get github.com/aarol/reload` 8 | 9 | ## Usage 10 | 11 | 1. Create a new Reloader and insert the middleware to your handler chain: 12 | 13 | ```go 14 | // handler can be anything that implements http.Handler, 15 | // like chi.Router, echo.Echo or gin.Engine 16 | var handler http.Handler = http.DefaultServeMux 17 | 18 | if isDevelopment { 19 | // Call `New()` with a list of directories to recursively watch 20 | reloader := reload.New("ui/") 21 | 22 | // Optionally, define a callback to 23 | // invalidate any caches 24 | reloader.OnReload = func() { 25 | app.parseTemplates() 26 | } 27 | 28 | // Use the Handle() method as a middleware 29 | handler = reloader.Handle(handler) 30 | } 31 | 32 | http.ListenAndServe(addr, handler) 33 | ``` 34 | 35 | 2. Run your application, make changes to files in the specified directories, and see the updated page instantly! 36 | 37 | See the full example at 38 | 39 | > [!NOTE] 40 | > The browser often caches resources served by http.FileServer (because of Last-Modified headers generated from filesystem timestamps), 41 | > which leads to unexpected caching issues when using this middleware. To prevent this, the middleware sends a "Cache-Control: no-cache" header. This is enabled by default, but can be disabled by setting the `DisableCaching` field to `false`. 42 | 43 | ### Logging 44 | 45 | By default, reload logs errors on `stderr`, with `(*Reloader).ErrorLog`. Error logging can be disabled by setting `ErrorLog` to `nil`. 46 | 47 | There is also a logger which reports debug information, exposed as `(*Reloader).DebugLog` (`nil` by default) 48 | 49 | ## How it works 50 | 51 | When added to the top of the middleware chain, `(*Reloader).Handle()` will inject a small ``, endpoint, wsCurrentVersion) 206 | } 207 | 208 | func (r *Reloader) logDebug(format string, v ...any) { 209 | if r.DebugLog != nil { 210 | r.DebugLog.Printf(format, v...) 211 | } 212 | } 213 | 214 | func (r *Reloader) logError(format string, v ...any) { 215 | if r.ErrorLog != nil { 216 | r.ErrorLog.Printf(format, v...) 217 | } 218 | } 219 | 220 | // fixedBuffer implements io.Writer and writes to a fixed-size []byte slice. 221 | type fixedBuffer struct { 222 | buf []byte 223 | pos int 224 | } 225 | 226 | func (w *fixedBuffer) Write(p []byte) (n int, err error) { 227 | if w.pos >= len(w.buf) { 228 | return 0, nil 229 | } 230 | 231 | n = copy(w.buf[w.pos:], p) 232 | w.pos += n 233 | 234 | return n, nil 235 | } 236 | -------------------------------------------------------------------------------- /watch.go: -------------------------------------------------------------------------------- 1 | package reload 2 | 3 | import ( 4 | "errors" 5 | "io/fs" 6 | "path" 7 | "path/filepath" 8 | "strings" 9 | "time" 10 | 11 | "github.com/bep/debounce" 12 | "github.com/fsnotify/fsnotify" 13 | ) 14 | 15 | // WatchDirectories listens for changes in directories and 16 | // broadcasts on write. 17 | func (reload *Reloader) WatchDirectories() { 18 | if len(reload.directories) == 0 { 19 | reload.logError("no directories provided (reload.Directories is empty)\n") 20 | return 21 | } 22 | 23 | w, err := fsnotify.NewWatcher() 24 | if err != nil { 25 | reload.logError("error initializing fsnotify watcher: %s\n", err) 26 | } 27 | 28 | defer w.Close() 29 | 30 | for _, path := range reload.directories { 31 | directories, err := recursiveWalk(path) 32 | if err != nil { 33 | var pathErr *fs.PathError 34 | if errors.As(err, &pathErr) { 35 | reload.logError("directory doesn't exist: %s\n", pathErr.Path) 36 | } else { 37 | reload.logError("error walking directories: %s\n", err) 38 | } 39 | return 40 | } 41 | for _, dir := range directories { 42 | // Path is converted to absolute path, so that fsnotify.Event also contains 43 | // absolute paths 44 | absPath, err := filepath.Abs(dir) 45 | if err != nil { 46 | reload.logError("Failed to convert path to absolute path: %s\n", err) 47 | continue 48 | } 49 | w.Add(absPath) 50 | } 51 | } 52 | 53 | reload.logDebug("watching %s for changes\n", strings.Join(reload.directories, ",")) 54 | 55 | debounce := debounce.New(100 * time.Millisecond) 56 | 57 | callback := func(path string) func() { 58 | return func() { 59 | reload.logDebug("Edit %s\n", path) 60 | if reload.OnReload != nil { 61 | reload.OnReload() 62 | } 63 | reload.cond.Broadcast() 64 | } 65 | } 66 | 67 | for { 68 | select { 69 | case err := <-w.Errors: 70 | reload.logError("error watching: %s \n", err) 71 | case e := <-w.Events: 72 | switch { 73 | case e.Has(fsnotify.Create): 74 | dir := filepath.Dir(e.Name) 75 | // Watch any created directory 76 | if err := w.Add(dir); err != nil { 77 | reload.logError("error watching %s: %s\n", e.Name, err) 78 | continue 79 | } 80 | debounce(callback(path.Base(e.Name))) 81 | 82 | case e.Has(fsnotify.Write): 83 | debounce(callback(path.Base(e.Name))) 84 | 85 | case e.Has(fsnotify.Rename), e.Has(fsnotify.Remove): 86 | // a renamed file might be outside the specified paths 87 | directories, _ := recursiveWalk(e.Name) 88 | for _, v := range directories { 89 | w.Remove(v) 90 | } 91 | w.Remove(e.Name) 92 | } 93 | } 94 | } 95 | } 96 | 97 | func recursiveWalk(path string) ([]string, error) { 98 | var res []string 99 | err := filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error { 100 | if err != nil { 101 | return err 102 | } 103 | if d.IsDir() { 104 | res = append(res, path) 105 | } 106 | return nil 107 | }) 108 | 109 | return res, err 110 | } 111 | -------------------------------------------------------------------------------- /wrap_writer.go: -------------------------------------------------------------------------------- 1 | package reload 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "net" 7 | "net/http" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | // The following was copied and modified from Chi's WrapWriter middleware (https://github.com/go-chi/chi/blob/6ceb498b221efa11f559602beb2463b8b52c2161/middleware/wrap_writer.go) 13 | // The original work was derived from Goji's middleware (https://github.com/zenazn/goji/tree/master/web/middleware) 14 | 15 | func newWrapResponseWriter(w http.ResponseWriter, protoMajor int, scriptSize int) wrapResponseWriter { 16 | _, fl := w.(http.Flusher) 17 | 18 | bw := basicWriter{ResponseWriter: w, scriptSize: scriptSize} 19 | 20 | if protoMajor == 2 { 21 | _, ps := w.(http.Pusher) 22 | if fl && ps { 23 | return &http2FancyWriter{bw} 24 | } 25 | } else { 26 | _, hj := w.(http.Hijacker) 27 | _, rf := w.(io.ReaderFrom) 28 | if fl && hj && rf { 29 | return &httpFancyWriter{bw} 30 | } 31 | if fl && hj { 32 | return &flushHijackWriter{bw} 33 | } 34 | if hj { 35 | return &hijackWriter{bw} 36 | } 37 | } 38 | 39 | if fl { 40 | return &flushWriter{bw} 41 | } 42 | 43 | return &bw 44 | } 45 | 46 | // wrapResponseWriter is a proxy around an http.ResponseWriter that allows you to hook 47 | // into various parts of the response process. 48 | type wrapResponseWriter interface { 49 | http.ResponseWriter 50 | // Status returns the HTTP status of the request, or 0 if one has not 51 | // yet been sent. 52 | Status() int 53 | // Tee causes the response body to be written to the given io.Writer in 54 | // addition to proxying the writes through. Only one io.Writer can be 55 | // tee'd to at once: setting a second one will overwrite the first. 56 | // Writes will be sent to the proxy before being written to this 57 | // io.Writer. It is illegal for the tee'd writer to be modified 58 | // concurrently with writes. 59 | Tee(io.Writer) 60 | // Unwrap returns the original proxied target. 61 | Unwrap() http.ResponseWriter 62 | } 63 | 64 | // basicWriter wraps a http.ResponseWriter that implements the minimal 65 | // http.ResponseWriter interface. 66 | type basicWriter struct { 67 | http.ResponseWriter 68 | scriptSize int 69 | wroteHeader bool 70 | code int 71 | bytes int 72 | tee io.Writer 73 | } 74 | 75 | func (b *basicWriter) WriteHeader(code int) { 76 | 77 | headers := b.Header() 78 | 79 | // If the Content-Length is set, and the Content-Type is HTML, then injecting 80 | // the script at the end will not work (typically this is done by http.FileServer) 81 | // 82 | // Handle this by changing Content-Size before sending the headers to include space for the script 83 | if contentLength := headers.Get("Content-Length"); contentLength != "" { 84 | if strings.HasPrefix(headers.Get("Content-Type"), "text/html") { 85 | cl, err := strconv.Atoi(contentLength) 86 | // ignore if failed to parse 87 | if err == nil { 88 | newLength := strconv.Itoa(cl + b.scriptSize) 89 | headers.Set("Content-Length", newLength) 90 | } 91 | } 92 | } 93 | 94 | if !b.wroteHeader { 95 | b.code = code 96 | b.wroteHeader = true 97 | b.ResponseWriter.WriteHeader(code) 98 | } 99 | } 100 | 101 | func (b *basicWriter) Write(buf []byte) (int, error) { 102 | b.maybeWriteHeader() 103 | n, err := b.ResponseWriter.Write(buf) 104 | if b.tee != nil { 105 | b.tee.Write(buf[:n]) 106 | } 107 | b.bytes += n 108 | return n, err 109 | } 110 | 111 | func (b *basicWriter) maybeWriteHeader() { 112 | if !b.wroteHeader { 113 | b.WriteHeader(http.StatusOK) 114 | } 115 | } 116 | 117 | func (b *basicWriter) Status() int { 118 | return b.code 119 | } 120 | 121 | func (b *basicWriter) BytesWritten() int { 122 | return b.bytes 123 | } 124 | 125 | func (b *basicWriter) Tee(w io.Writer) { 126 | b.tee = w 127 | } 128 | 129 | func (b *basicWriter) Unwrap() http.ResponseWriter { 130 | return b.ResponseWriter 131 | } 132 | 133 | // flushWriter ... 134 | type flushWriter struct { 135 | basicWriter 136 | } 137 | 138 | func (f *flushWriter) Flush() { 139 | f.wroteHeader = true 140 | fl := f.basicWriter.ResponseWriter.(http.Flusher) 141 | fl.Flush() 142 | } 143 | 144 | var _ http.Flusher = &flushWriter{} 145 | 146 | // hijackWriter ... 147 | type hijackWriter struct { 148 | basicWriter 149 | } 150 | 151 | func (f *hijackWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { 152 | hj := f.basicWriter.ResponseWriter.(http.Hijacker) 153 | return hj.Hijack() 154 | } 155 | 156 | var _ http.Hijacker = &hijackWriter{} 157 | 158 | // flushHijackWriter ... 159 | type flushHijackWriter struct { 160 | basicWriter 161 | } 162 | 163 | func (f *flushHijackWriter) Flush() { 164 | f.wroteHeader = true 165 | fl := f.basicWriter.ResponseWriter.(http.Flusher) 166 | fl.Flush() 167 | } 168 | 169 | func (f *flushHijackWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { 170 | hj := f.basicWriter.ResponseWriter.(http.Hijacker) 171 | return hj.Hijack() 172 | } 173 | 174 | var ( 175 | _ http.Flusher = &flushHijackWriter{} 176 | _ http.Hijacker = &flushHijackWriter{} 177 | ) 178 | 179 | // httpFancyWriter is a HTTP writer that additionally satisfies 180 | // http.Flusher, http.Hijacker, and io.ReaderFrom. It exists for the common case 181 | // of wrapping the http.ResponseWriter that package http gives you, in order to 182 | // make the proxied object support the full method set of the proxied object. 183 | type httpFancyWriter struct { 184 | basicWriter 185 | } 186 | 187 | func (f *httpFancyWriter) Flush() { 188 | f.wroteHeader = true 189 | fl := f.basicWriter.ResponseWriter.(http.Flusher) 190 | fl.Flush() 191 | } 192 | 193 | func (f *httpFancyWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { 194 | hj := f.basicWriter.ResponseWriter.(http.Hijacker) 195 | return hj.Hijack() 196 | } 197 | 198 | func (f *http2FancyWriter) Push(target string, opts *http.PushOptions) error { 199 | return f.basicWriter.ResponseWriter.(http.Pusher).Push(target, opts) 200 | } 201 | 202 | func (f *httpFancyWriter) ReadFrom(r io.Reader) (int64, error) { 203 | if f.basicWriter.tee != nil { 204 | n, err := io.Copy(&f.basicWriter, r) 205 | f.basicWriter.bytes += int(n) 206 | return n, err 207 | } 208 | rf := f.basicWriter.ResponseWriter.(io.ReaderFrom) 209 | f.basicWriter.maybeWriteHeader() 210 | n, err := rf.ReadFrom(r) 211 | f.basicWriter.bytes += int(n) 212 | return n, err 213 | } 214 | 215 | var ( 216 | _ http.Flusher = &httpFancyWriter{} 217 | _ http.Hijacker = &httpFancyWriter{} 218 | _ http.Pusher = &http2FancyWriter{} 219 | _ io.ReaderFrom = &httpFancyWriter{} 220 | ) 221 | 222 | // http2FancyWriter is a HTTP2 writer that additionally satisfies 223 | // http.Flusher, and io.ReaderFrom. It exists for the common case 224 | // of wrapping the http.ResponseWriter that package http gives you, in order to 225 | // make the proxied object support the full method set of the proxied object. 226 | type http2FancyWriter struct { 227 | basicWriter 228 | } 229 | 230 | func (f *http2FancyWriter) Flush() { 231 | f.wroteHeader = true 232 | fl := f.basicWriter.ResponseWriter.(http.Flusher) 233 | fl.Flush() 234 | } 235 | 236 | var _ http.Flusher = &http2FancyWriter{} 237 | --------------------------------------------------------------------------------