├── README.md ├── constants.go ├── http-watcher.go └── preprocess /README.md: -------------------------------------------------------------------------------- 1 | # HTTP Watcher 2 | 3 | A server that automatically reload browsers when file changed, help developers focus on coding. 4 | 5 | No copy and paste javascript code needed, just start `http-watcher`, that's all. 6 | 7 | > 8 | Web Server for Web developers! HTTP Watcher = HTTP file Server + HTTP proxy + Directory Watcher: automatically reload connected browsers when file changed, works for both static and dynamic web project. 9 | 10 | ### build 11 | 12 | ```sh 13 | # go get github.com/howeyc/fsnotify 14 | go build # you may want to copy http-watcher binary to $PATH for easy use. prebuilt binary comming soon 15 | ``` 16 | 17 | ### Usage 18 | 19 | ```sh 20 | http-watcher args # acceptable args list below, -h to show them 21 | ``` 22 | ```sh 23 | -command="": Command to run before reload browser, useful for preprocess, like compile scss. The files been chaneged, along with event type are pass as arguments 24 | -ignores="": Ignored file pattens, seprated by ',', used to ignore the filesystem events of some files 25 | -monitor=true: Enable monitor filesystem event 26 | -port=8000: Which port to listen 27 | -private=false: Only listen on lookback interface, otherwise listen on all interface 28 | -proxy=0: Local dynamic site's port number, like 8080, HTTP watcher proxy it, automatically reload browsers when watched directory's file changed 29 | -root=".": Watched root directory for filesystem events, also the HTTP File Server's root directory 30 | ``` 31 | 32 | ### HTML + JS + CSS (static web project) 33 | 34 | ```sh 35 | http-watcher -port 8000 -root /your/code/root 36 | ``` 37 | 38 | ### Dynamic web site: Clojure, golang, Python, JAVA 39 | 40 | ```sh 41 | # your dynamic site listen on 9090 42 | # http-watcher act as a proxy 43 | http-watcher -port 8000 -root /your/code/root -proxy=9090 -ignores test/,classes 44 | ``` 45 | ### HTTP file server, no filesystem monitoring 46 | 47 | ```sh 48 | # like python -m SimpleHTTPServer, should handle concurrency better 49 | http-watcher -monitor=false 50 | ``` 51 | 52 | ### Web browser ### 53 | 54 | Add the following HTML code to your `index.html`: 55 | 56 | ``` 57 | 58 | ``` 59 | 60 | Manually reload the page in your browser. The browser Javascript console should display a message like: `http-watcher reload connected`. From that point on, any file changes should cause the page to be automatically reloaded. 61 | -------------------------------------------------------------------------------- /constants.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | const ( 4 | MODIFY = "MODIFY" 5 | ADD = "ADD" 6 | REMOVE = "REMOVE" 7 | RELOAD_JS = `(function () { 8 | var added = false; 9 | function add_js () { 10 | if(added) { return; } 11 | var js = document.createElement('script'); 12 | js.src = "http://{{.}}/_d/polling"; 13 | var scripts = document.getElementsByTagName('script'), 14 | s = scripts[scripts.length - 1]; 15 | s.parentNode.insertBefore(js, s); 16 | if(window.console && console.log) { 17 | console.log("http-watcher reload connected"); 18 | } 19 | added = true; 20 | } 21 | 22 | setTimeout(function(){ 23 | setTimeout(add_js, 600); 24 | window.onload = add_js; 25 | }, 600) 26 | })();` 27 | DIR_HTML = ` 28 | 29 | 30 | 31 | 32 | Directory Listing: {{.dir}} 33 | 68 | 69 | 70 |
71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | {{range .files}} 79 | 80 | 81 | 82 | 83 | 84 | {{end}} 85 |
Directory List: {{.dir}}
FileSizeLast Modified
{{.name}}{{ .size }}{{ .mtime }}
86 | 90 |
91 | 92 | ` 93 | HELP_HTML = ` 94 | 95 | 96 | 97 | 98 | HTTP watcher Documentation 99 | 129 | 130 | 131 |

HTTP Watcher Documentation

132 | {{if .error}} 133 |

ERROR: {{.error}}

134 | {{end}} 135 | 136 |

Directory been watched for changed

137 |

{{.dir}}

138 |
139 |

Ignore file pattens:

140 |
    141 | {{range .ignores}}
  1. {{.}}
  2. {{end}} 142 |
143 |
144 |

Visit (automatically reload when file changes detected)

145 | 150 |

Command Help

151 |
http-watcher -h
152 | 153 | 157 | 158 | `) 159 | -------------------------------------------------------------------------------- /http-watcher.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "log" 10 | "mime" 11 | "net" 12 | "net/http" 13 | "os" 14 | "os/exec" 15 | "path" 16 | "path/filepath" 17 | "regexp" 18 | "strconv" 19 | "strings" 20 | "sync" 21 | "text/template" 22 | "time" 23 | 24 | "github.com/howeyc/fsnotify" 25 | ) 26 | 27 | type Client struct { 28 | buf *bufio.ReadWriter 29 | conn net.Conn 30 | } 31 | 32 | type ReloadMux struct { 33 | mu sync.Mutex 34 | port int 35 | ignores string 36 | ignorePattens []*regexp.Regexp 37 | command string 38 | root string 39 | reloadJs *template.Template 40 | dirListTmpl *template.Template 41 | docTmpl *template.Template 42 | clients []Client 43 | private bool 44 | proxy int 45 | monitor bool 46 | fsWatcher *fsnotify.Watcher 47 | delay float64 48 | } 49 | 50 | var reloadCfg = ReloadMux{ 51 | clients: make([]Client, 0), 52 | } 53 | 54 | func shouldIgnore(file string) bool { 55 | for _, p := range reloadCfg.ignorePattens { 56 | if p.Find([]byte(file)) != nil { 57 | return true 58 | } 59 | } 60 | 61 | base := path.Base(file) 62 | // ignore hidden file and emacs generated file 63 | if len(base) > 1 && (strings.HasPrefix(base, ".") || strings.HasPrefix(base, "#")) { 64 | return true 65 | } 66 | 67 | return false 68 | } 69 | 70 | func showDoc(w http.ResponseWriter, req *http.Request, err error) { 71 | if err != nil { 72 | w.WriteHeader(404) 73 | } else { 74 | w.WriteHeader(200) 75 | } 76 | w.Header().Add("Content-Type", "text/html; charset=utf-8") 77 | abs, _ := filepath.Abs(".") 78 | reloadCfg.docTmpl.Execute(w, map[string]interface{}{ 79 | "error": err, 80 | "dir": abs, 81 | "ignores": reloadCfg.ignorePattens, 82 | "hosts": publicHosts(), 83 | }) 84 | 85 | } 86 | 87 | func publicHosts() []string { 88 | ips := make([]string, 0) 89 | if reloadCfg.private { 90 | ips = append(ips, "127.0.0.1:"+strconv.Itoa(reloadCfg.port)) 91 | } else { 92 | if addrs, err := net.InterfaceAddrs(); err == nil { 93 | r, _ := regexp.Compile(`(\d+\.){3}\d+`) 94 | for _, addr := range addrs { 95 | ip := addr.String() 96 | if strings.Contains(ip, "/") { 97 | ip = strings.Split(ip, "/")[0] 98 | } 99 | if r.Match([]byte(ip)) { 100 | ips = append(ips, ip+":"+strconv.Itoa(reloadCfg.port)) 101 | } 102 | } 103 | } 104 | } 105 | return ips 106 | } 107 | 108 | func getAllFileMeta() map[string]time.Time { 109 | files := map[string]time.Time{} 110 | 111 | walkFn := func(path string, info os.FileInfo, err error) error { 112 | if err != nil { // TODO permisstion denyed 113 | } 114 | ignore := shouldIgnore(path) 115 | if !info.IsDir() && !ignore { 116 | files[path] = info.ModTime() 117 | } 118 | 119 | if info.IsDir() && ignore { 120 | return filepath.SkipDir 121 | } 122 | return nil 123 | } 124 | 125 | if err := filepath.Walk(reloadCfg.root, walkFn); err != nil { 126 | log.Println(err) 127 | } 128 | 129 | return files 130 | } 131 | 132 | func formatSize(file os.FileInfo) string { 133 | if file.IsDir() { 134 | return "-" 135 | } 136 | size := file.Size() 137 | switch { 138 | case size > 1024*1024: 139 | return fmt.Sprintf("%.1fM", float64(size)/1024/1024) 140 | case size > 1024: 141 | return fmt.Sprintf("%.1fk", float64(size)/1024) 142 | default: 143 | return strconv.Itoa(int(size)) 144 | } 145 | return "" 146 | } 147 | 148 | func dirList(w http.ResponseWriter, f *os.File) { 149 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 150 | if dirs, err := f.Readdir(-1); err == nil { 151 | files := make([]map[string]string, len(dirs)+1) 152 | files[0] = map[string]string{ 153 | "name": "..", "href": "..", "size": "-", "mtime": "-", 154 | } 155 | for i, d := range dirs { 156 | href := d.Name() 157 | if d.IsDir() { 158 | href += "/" 159 | } 160 | files[i+1] = map[string]string{ 161 | "name": d.Name(), 162 | "href": href, 163 | "size": formatSize(d), 164 | "mtime": d.ModTime().Format("2006-01-02 15:04:05"), 165 | } 166 | } 167 | reloadCfg.dirListTmpl.Execute(w, map[string]interface{}{ 168 | "dir": f.Name(), 169 | "files": files, 170 | }) 171 | } 172 | } 173 | 174 | func reloadHandler(w http.ResponseWriter, path string, req *http.Request) { 175 | switch path { 176 | case "/js": 177 | w.Header().Add("Content-Type", "text/javascript") 178 | w.Header().Add("Cache-Control", "no-cache") 179 | reloadCfg.reloadJs.Execute(w, req.Host) 180 | case "/polling": 181 | hj, ok := w.(http.Hijacker) 182 | if !ok { 183 | http.Error(w, "webserver doesn't support hijacking", http.StatusInternalServerError) 184 | return 185 | } 186 | conn, bufrw, err := hj.Hijack() 187 | if err != nil { 188 | http.Error(w, err.Error(), http.StatusInternalServerError) 189 | return 190 | } 191 | reloadCfg.mu.Lock() 192 | reloadCfg.clients = append(reloadCfg.clients, Client{bufrw, conn}) 193 | reloadCfg.mu.Unlock() 194 | default: 195 | showDoc(w, req, nil) 196 | } 197 | } 198 | 199 | func appendReloadHook(w http.ResponseWriter, ctype string, req *http.Request) { 200 | if reloadCfg.monitor && strings.HasPrefix(ctype, "text/html") { 201 | w.Write([]byte("")) 202 | } 203 | } 204 | 205 | func fileHandler(w http.ResponseWriter, path string, req *http.Request) { 206 | if path == "" { 207 | path = "." 208 | } 209 | f, err := os.Open(path) 210 | if err != nil { 211 | log.Println(err) 212 | showDoc(w, req, err) 213 | return 214 | } 215 | defer f.Close() 216 | 217 | d, err1 := f.Stat() 218 | if err1 != nil { 219 | log.Println(err) 220 | showDoc(w, req, err) 221 | return 222 | } 223 | 224 | if d.IsDir() { 225 | dirList(w, f) 226 | } else { 227 | ctype := mime.TypeByExtension(filepath.Ext(path)) 228 | if ctype != "" { 229 | // go return charset=utf8 even if the charset is not utf8 230 | idx := strings.Index(ctype, "; ") 231 | if idx > 0 { 232 | // remove charset; anyway, browsers are very good at guessing it. 233 | ctype = ctype[0:idx] 234 | } 235 | w.Header().Set("Content-Type", ctype) 236 | } 237 | if fi, err := os.Stat(path); err == nil { 238 | w.Header().Set("Content-Length", fmt.Sprintf("%d", fi.Size())) 239 | } 240 | w.WriteHeader(200) 241 | io.Copy(w, f) 242 | appendReloadHook(w, ctype, req) 243 | } 244 | } 245 | 246 | // proxy dynamic website, add the reload hook if HTML 247 | func proxyHandler(w http.ResponseWriter, req *http.Request) { 248 | host := "http://127.0.0.1:" + strconv.Itoa(reloadCfg.proxy) 249 | url := host + req.URL.String() 250 | if request, err := http.NewRequest(req.Method, url, req.Body); err == nil { 251 | request.Header.Add("X-Forwarded-For", strings.Split(req.RemoteAddr, ":")[0]) 252 | // Host is removed from req.Header by go 253 | for k, values := range req.Header { 254 | for _, v := range values { 255 | request.Header.Add(k, v) 256 | } 257 | } 258 | request.ContentLength = req.ContentLength 259 | request.Close = true 260 | // do not follow any redirect, browser will do that 261 | if resp, err := http.DefaultTransport.RoundTrip(request); err == nil { 262 | for k, values := range resp.Header { 263 | for _, v := range values { 264 | // Transfer-Encoding:chunked, for append reload hook 265 | if k != "Content-Length" { 266 | w.Header().Add(k, v) 267 | } 268 | } 269 | } 270 | // Host is set by go 271 | defer resp.Body.Close() 272 | w.WriteHeader(resp.StatusCode) 273 | io.Copy(w, resp.Body) 274 | appendReloadHook(w, w.Header().Get("Content-Type"), req) 275 | } else { 276 | showDoc(w, req, err) // remote may refuse connection 277 | } 278 | } else { 279 | log.Fatal(err) 280 | } 281 | } 282 | 283 | func handler(w http.ResponseWriter, req *http.Request) { 284 | path := req.URL.Path 285 | if len(path) > 3 && path[0:3] == "/_d" { 286 | reloadHandler(w, path[3:], req) 287 | } else if reloadCfg.proxy == 0 { 288 | fileHandler(w, path[1:], req) 289 | } else { 290 | proxyHandler(w, req) 291 | } 292 | } 293 | 294 | func startMonitorFs() { 295 | watcher, err := fsnotify.NewWatcher() 296 | if err != nil { 297 | log.Fatal(err) 298 | } else { 299 | reloadCfg.fsWatcher = watcher 300 | walkFn := func(path string, info os.FileInfo, err error) error { 301 | if err != nil { // TODO permisstion denyed 302 | } 303 | ignore := shouldIgnore(path) 304 | if ignore && info.IsDir() { 305 | log.Println("ignore dir", path) 306 | return filepath.SkipDir 307 | } 308 | if info.IsDir() && !ignore { 309 | err = watcher.Watch(path) 310 | if err != nil { 311 | log.Fatal(err) 312 | } else { 313 | log.Println("monitoring dir", path) 314 | } 315 | } 316 | return nil 317 | } 318 | if err := filepath.Walk(reloadCfg.root, walkFn); err != nil { 319 | log.Println(err) 320 | } 321 | } 322 | } 323 | 324 | func compilePattens() { 325 | reloadCfg.mu.Lock() 326 | defer reloadCfg.mu.Unlock() 327 | ignores := strings.Split(reloadCfg.ignores, ",") 328 | pattens := make([]*regexp.Regexp, 0) 329 | for _, s := range ignores { 330 | if len(s) > 0 { 331 | if p, e := regexp.Compile(s); e == nil { 332 | pattens = append(pattens, p) 333 | } else { 334 | log.Println("ERROR: can not compile to regex", s, e) 335 | } 336 | } 337 | } 338 | reloadCfg.ignorePattens = pattens 339 | } 340 | 341 | func notifyBrowsers() { 342 | reloadCfg.mu.Lock() 343 | defer reloadCfg.mu.Unlock() 344 | for _, c := range reloadCfg.clients { 345 | defer c.conn.Close() 346 | reload := "HTTP/1.1 200 OK\r\n" 347 | reload += "Cache-Control: no-cache\r\nContent-Type: text/javascript\r\n\r\n" 348 | reload += fmt.Sprintf("setTimeout(function(){location.reload(true)}, %f*1000);", reloadCfg.delay) 349 | c.buf.Write([]byte(reload)) 350 | c.buf.Flush() 351 | } 352 | reloadCfg.clients = make([]Client, 0) 353 | } 354 | 355 | // remove duplicate, and file name contains # 356 | func cleanEvents(events []*fsnotify.FileEvent) []*fsnotify.FileEvent { 357 | m := map[string]bool{} 358 | for _, v := range events { 359 | if _, seen := m[v.Name]; !seen { 360 | base := path.Base(v.Name) 361 | if !strings.Contains(base, "#") { 362 | events[len(m)] = v 363 | m[v.Name] = true 364 | } 365 | } 366 | } 367 | return events[:len(m)] 368 | } 369 | 370 | func processFsEvents() { 371 | events := make([]*fsnotify.FileEvent, 0) 372 | timer := time.Tick(100 * time.Millisecond) 373 | for { 374 | select { 375 | case <-timer: // combine events 376 | events = cleanEvents(events) 377 | if len(events) > 0 { 378 | command := reloadCfg.command 379 | if command != "" { 380 | args := make([]string, len(events)*2) 381 | for i, e := range events { 382 | args[2*i] = MODIFY 383 | if e.IsCreate() { 384 | args[2*i] = ADD 385 | } else if e.IsDelete() { 386 | args[2*i] = REMOVE 387 | } 388 | args[2*i+1] = e.Name 389 | } 390 | sub := exec.Command(command, args...) 391 | var out bytes.Buffer 392 | sub.Stdout = &out 393 | err := sub.Run() 394 | if err == nil { 395 | log.Println("run "+command+" ok; output: ", out.String()) 396 | notifyBrowsers() 397 | } else { 398 | log.Println("ERROR running "+command, err) 399 | } 400 | } else { 401 | notifyBrowsers() 402 | } 403 | events = make([]*fsnotify.FileEvent, 0) 404 | } 405 | case ev := <-reloadCfg.fsWatcher.Event: 406 | if ev.IsDelete() { 407 | reloadCfg.fsWatcher.RemoveWatch(ev.Name) 408 | events = append(events, ev) 409 | } else { 410 | fi, e := os.Lstat(ev.Name) 411 | if e != nil { 412 | log.Println(e) 413 | } else if fi.IsDir() { 414 | if !shouldIgnore(ev.Name) { 415 | reloadCfg.fsWatcher.Watch(ev.Name) 416 | } 417 | } else { 418 | if !shouldIgnore(ev.Name) { 419 | events = append(events, ev) 420 | } 421 | } 422 | } 423 | } 424 | } 425 | } 426 | 427 | func main() { 428 | flag.IntVar(&(reloadCfg.port), "port", 8000, "Which port to listen") 429 | flag.StringVar(&(reloadCfg.root), "root", ".", "Watched root directory for filesystem events, also the HTTP File Server's root directory") 430 | flag.StringVar(&(reloadCfg.command), "command", "", "Command to run before reload browser, useful for preprocess, like compile scss. The files been chaneged, along with event type are pass as arguments") 431 | flag.StringVar(&(reloadCfg.ignores), "ignores", "", "Ignored file pattens, seprated by ',', used to ignore the filesystem events of some files") 432 | flag.BoolVar(&(reloadCfg.private), "private", false, "Only listen on lookback interface, otherwise listen on all interface") 433 | flag.IntVar(&(reloadCfg.proxy), "proxy", 0, "Local dynamic site's port number, like 8080, HTTP watcher proxy it, automatically reload browsers when watched directory's file changed") 434 | flag.BoolVar(&(reloadCfg.monitor), "monitor", true, "Enable monitor filesystem event") 435 | flag.Float64Var(&(reloadCfg.delay), "delay", 0, "Delay in seconds before reload browser.") 436 | flag.Parse() 437 | 438 | if _, e := os.Open(reloadCfg.command); e == nil { 439 | // turn to abs path if exits 440 | abs, _ := filepath.Abs(reloadCfg.command) 441 | reloadCfg.command = abs 442 | } 443 | 444 | // compile templates 445 | t, _ := template.New("reloadjs").Parse(RELOAD_JS) 446 | reloadCfg.reloadJs = t 447 | t, _ = template.New("dirlist").Parse(DIR_HTML) 448 | reloadCfg.dirListTmpl = t 449 | t, _ = template.New("doc").Parse(HELP_HTML) 450 | reloadCfg.docTmpl = t 451 | 452 | // log.SetFlags(log.LstdFlags | log.Lshortfile) 453 | compilePattens() 454 | if e := os.Chdir(reloadCfg.root); e != nil { 455 | log.Panic(e) 456 | } 457 | if reloadCfg.monitor { 458 | startMonitorFs() 459 | go processFsEvents() 460 | } 461 | http.HandleFunc("/", handler) 462 | 463 | int := ":" + strconv.Itoa(reloadCfg.port) 464 | p := strconv.Itoa(reloadCfg.port) 465 | mesg := "" 466 | if reloadCfg.proxy != 0 { 467 | mesg += "; proxy site http://127.0.0.1:" + strconv.Itoa(reloadCfg.proxy) 468 | } 469 | mesg += "; please visit http://127.0.0.1:" + p 470 | if reloadCfg.private { 471 | int = "localhost" + int 472 | log.Printf("listens on 127.0.0.1@" + p + mesg) 473 | } else { 474 | log.Printf("listens on 0.0.0.0@" + p + mesg) 475 | } 476 | if err := http.ListenAndServe(int, nil); err != nil { 477 | log.Fatal(err) 478 | } 479 | } 480 | -------------------------------------------------------------------------------- /preprocess: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | if [ $# -eq 0 ]; then # show help 4 | echo "Used to preprocss some file: like compile scss, cleanup html before reload browsers" 5 | exit 1 6 | fi 7 | 8 | # this script is runned with CWD = root param passed to http-watcher 9 | 10 | notify_changed() { 11 | # ping server, server knows how to reload the file 12 | wget http://127.0.0.1:8000/dev/changed?f=$1 -O - 13 | } 14 | 15 | compile_scss() { 16 | sass -t compressed --cache-location /tmp $1 $2 17 | } 18 | 19 | compress_html() { 20 | java -jar thirdparty/htmlcompressor.jar \ 21 | --type html \ 22 | --charset utf8 \ 23 | --remove-quotes \ 24 | --remove-script-attr \ 25 | --remove-link-attr \ 26 | --remove-style-attr \ 27 | --simple-bool-attr \ 28 | --remove-intertag-spaces $1 -o $2 29 | } 30 | 31 | # [event file] pairs are passed as command line args 32 | while [ $# -ne 0 ]; do 33 | event=$1; shift # event name 34 | file=$1; shift # file name 35 | extention=${file##*.} # file extension 36 | echo $file $event 37 | case $extention in 38 | scss) # compile scss to css 39 | mkdir -p public/css/ 40 | for scss in $(find . -name "[^_]*.scss"); do 41 | name=$(basename $scss) # filename 42 | name="${name%.*}" # remove extension 43 | compile_scss $scss public/css/$name.css 44 | done 45 | ;; 46 | tpl) # compress the changed mustache template using htmlcompressor 47 | mkdir -p src/templates 48 | # compress files in templates to src/templates 49 | target=$(echo $file | sed -e 's/templates/src\/templates/') 50 | compress_html $file $target 51 | notify_changed $file 52 | ;; 53 | clj) 54 | notify_changed $file 55 | ;; 56 | esac 57 | done 58 | 59 | # For rssminer 60 | # http-watcher -root ~/workspace/rssminer -ignores "test/,/\.,\.css$,.#,src/templates,target/,public/,android/" -proxy 9090 -command ./preprocess 61 | --------------------------------------------------------------------------------