github.com/vugu/vugu@v0.3.5/simplehttp/simple-handler.go (about) 1 /* 2 Package simplehttp provides an http.Handler that makes it easy to serve Vugu applications. 3 Useful for development and production. 4 5 The idea is that the common behaviors needed to serve a Vugu site are readily available 6 in one place. If you require more functionality than simplehttp provides, nearly everything 7 it does is available in the github.com/vugu/vugu package and you can construct what you 8 need from its parts. That said, simplehttp should make it easy to start: 9 10 11 // dev flag enables most common development features 12 // including rebuild your .wasm upon page reload 13 dev := true 14 h := simplehttp.New(dir, dev) 15 16 After creation, some flags are available for tuning, e.g.: 17 18 h.EnableGenerate = true // upon page reload run "go generate ." 19 h.DisableBuildCache = true // do not try to cache build results during development, just rebuild every time 20 h.ParserGoPkgOpts.SkipRegisterComponentTypes = true // do not generate component registration init() stuff 21 22 Since it's just a regular http.Handler, starting a webserver is as simple as: 23 24 log.Fatal(http.ListenAndServe("127.0.0.1:5678", h)) 25 26 */ 27 package simplehttp 28 29 import ( 30 "bytes" 31 "compress/gzip" 32 "fmt" 33 "html/template" 34 "io" 35 "io/ioutil" 36 "log" 37 "net/http" 38 "os" 39 "os/exec" 40 "path" 41 "path/filepath" 42 "regexp" 43 "strings" 44 "sync" 45 "time" 46 47 "github.com/vugu/vugu/gen" 48 ) 49 50 // SimpleHandler provides common web serving functionality useful for building Vugu sites. 51 type SimpleHandler struct { 52 Dir string // project directory 53 54 EnableBuildAndServe bool // enables the build-and-serve sequence for your wasm binary - useful for dev, should be off in production 55 EnableGenerate bool // if true calls `go generate` (requires EnableBuildAndServe) 56 ParserGoPkgOpts *gen.ParserGoPkgOpts // if set enables running ParserGoPkg with these options (requires EnableBuildAndServe) 57 DisableBuildCache bool // if true then rebuild every time instead of trying to cache (requires EnableBuildAndServe) 58 DisableTimestampPreservation bool // if true don't try to keep timestamps the same for files that are byte for byte identical (requires EnableBuildAndServe) 59 MainWasmPath string // path to serve main wasm file from, in dev mod defaults to "/main.wasm" (requires EnableBuildAndServe) 60 WasmExecJsPath string // path to serve wasm_exec.js from after finding in the local Go installation, in dev mode defaults to "/wasm_exec.js" 61 62 IsPage func(r *http.Request) bool // func that returns true if PageHandler should serve the request 63 PageHandler http.Handler // returns the HTML page 64 65 StaticHandler http.Handler // returns static assets from Dir with appropriate filtering or appropriate error 66 67 wasmExecJsOnce sync.Once 68 wasmExecJsContent []byte 69 wasmExecJsTs time.Time 70 71 lastBuildTime time.Time // time of last successful build 72 lastBuildContentGZ []byte // last successful build gzipped 73 74 mu sync.RWMutex 75 } 76 77 // New returns an SimpleHandler ready to serve using the specified directory. 78 // The dev flag indicates if development functionality is enabled. 79 // Settings on SimpleHandler may be tuned more specifically after creation, this function just 80 // returns sensible defaults for development or production according to if dev is true or false. 81 func New(dir string, dev bool) *SimpleHandler { 82 83 if !filepath.IsAbs(dir) { 84 panic(fmt.Errorf("dir %q is not an absolute path", dir)) 85 } 86 87 ret := &SimpleHandler{ 88 Dir: dir, 89 } 90 91 ret.IsPage = DefaultIsPageFunc 92 ret.PageHandler = &PageHandler{ 93 Template: template.Must(template.New("_page_").Parse(DefaultPageTemplateSource)), 94 TemplateDataFunc: DefaultTemplateDataFunc, 95 } 96 97 ret.StaticHandler = FilteredFileServer( 98 regexp.MustCompile(`[.](css|js|html|map|jpg|jpeg|png|gif|svg|eot|ttf|otf|woff|woff2|wasm)$`), 99 http.Dir(dir)) 100 101 if dev { 102 ret.EnableBuildAndServe = true 103 ret.ParserGoPkgOpts = &gen.ParserGoPkgOpts{} 104 ret.MainWasmPath = "/main.wasm" 105 ret.WasmExecJsPath = "/wasm_exec.js" 106 } 107 108 return ret 109 } 110 111 // ServeHTTP implements http.Handler. 112 func (h *SimpleHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 113 114 // by default we tell browsers to always check back with us for content, even in production; 115 // we allow disabling by the caller just setting another value first; otherwise too much 116 // headache caused by pages that won't reload and we still reduce a lot of bandwidth usage with 117 // 304 responses, seems like a sensible trade off for now 118 if w.Header().Get("Cache-Control") == "" { 119 w.Header().Set("Cache-Control", "max-age=0, no-cache") 120 } 121 122 p := path.Clean("/" + r.URL.Path) 123 124 if h.EnableBuildAndServe && h.MainWasmPath == p { 125 h.buildAndServe(w, r) 126 return 127 } 128 129 if h.WasmExecJsPath == p { 130 h.serveGoEnvWasmExecJs(w, r) 131 return 132 } 133 134 if h.IsPage(r) { 135 h.PageHandler.ServeHTTP(w, r) 136 return 137 } 138 139 h.StaticHandler.ServeHTTP(w, r) 140 } 141 142 func (h *SimpleHandler) buildAndServe(w http.ResponseWriter, r *http.Request) { 143 144 // EnableGenerate bool // if true calls `go generate` (requires EnableBuildAndServe) 145 146 // main.wasm and build process, first check if it's needed 147 148 h.mu.RLock() 149 lastBuildTime := h.lastBuildTime 150 lastBuildContentGZ := h.lastBuildContentGZ 151 h.mu.RUnlock() 152 153 var buildDirTs time.Time 154 var err error 155 156 if !h.DisableTimestampPreservation { 157 buildDirTs, err = dirTimestamp(h.Dir) 158 if err != nil { 159 log.Printf("error in dirTimestamp(%q): %v", h.Dir, err) 160 goto doBuild 161 } 162 } 163 164 if len(lastBuildContentGZ) == 0 { 165 // log.Printf("2") 166 goto doBuild 167 } 168 169 if h.DisableBuildCache { 170 goto doBuild 171 } 172 173 // skip build process if timestamp from build dir exists and is equal or older than our last build 174 if !buildDirTs.IsZero() && !buildDirTs.After(lastBuildTime) { 175 // log.Printf("3") 176 goto serveBuiltFile 177 } 178 179 // // a false return value means we should send a 304 180 // if !checkIfModifiedSince(r, buildDirTs) { 181 // w.WriteHeader(http.StatusNotModified) 182 // return 183 // } 184 185 // FIXME: might be useful to make it so only one thread rebuilds at a time and they both use the result 186 187 doBuild: 188 189 // log.Printf("GOT HERE") 190 191 { 192 193 if h.ParserGoPkgOpts != nil { 194 pg := gen.NewParserGoPkg(h.Dir, h.ParserGoPkgOpts) 195 err := pg.Run() 196 if err != nil { 197 msg := fmt.Sprintf("Error from ParserGoPkg: %v", err) 198 log.Print(msg) 199 http.Error(w, msg, 500) 200 return 201 } 202 } 203 204 f, err := ioutil.TempFile("", "main_wasm_") 205 if err != nil { 206 panic(err) 207 } 208 fpath := f.Name() 209 f.Close() 210 os.Remove(f.Name()) 211 defer os.Remove(f.Name()) 212 213 startTime := time.Now() 214 if h.EnableGenerate { 215 cmd := exec.Command("go", "generate", ".") 216 cmd.Dir = h.Dir 217 cmd.Env = append(cmd.Env, os.Environ()...) 218 b, err := cmd.CombinedOutput() 219 w.Header().Set("X-Go-Generate-Duration", time.Since(startTime).String()) 220 if err != nil { 221 msg := fmt.Sprintf("Error from generate: %v; Output:\n%s", err, b) 222 log.Print(msg) 223 http.Error(w, msg, 500) 224 return 225 } 226 } 227 228 // GOOS=js GOARCH=wasm go build -o main.wasm . 229 startTime = time.Now() 230 runCommand := func(args ...string) ([]byte, error) { 231 cmd := exec.Command(args[0], args[1:]...) 232 cmd.Dir = h.Dir 233 cmd.Env = append(cmd.Env, os.Environ()...) 234 cmd.Env = append(cmd.Env, "GOOS=js", "GOARCH=wasm") 235 b, err := cmd.CombinedOutput() 236 return b, err 237 } 238 b, err := runCommand("go", "mod", "tidy") 239 if err == nil { 240 b, err = runCommand("go", "build", "-o", fpath, ".") 241 } 242 w.Header().Set("X-Go-Build-Duration", time.Since(startTime).String()) 243 if err != nil { 244 msg := fmt.Sprintf("Error from compile: %v (out path=%q); Output:\n%s", err, fpath, b) 245 log.Print(msg) 246 http.Error(w, msg, 500) 247 return 248 } 249 250 f, err = os.Open(fpath) 251 if err != nil { 252 msg := fmt.Sprintf("Error opening file after build: %v", err) 253 log.Print(msg) 254 http.Error(w, msg, 500) 255 return 256 } 257 258 // gzip with max compression 259 var buf bytes.Buffer 260 gzw, _ := gzip.NewWriterLevel(&buf, gzip.BestCompression) 261 n, err := io.Copy(gzw, f) 262 if err != nil { 263 msg := fmt.Sprintf("Error reading and compressing binary: %v", err) 264 log.Print(msg) 265 http.Error(w, msg, 500) 266 return 267 } 268 gzw.Close() 269 270 w.Header().Set("X-Gunzipped-Size", fmt.Sprint(n)) 271 272 // update cache 273 274 if buildDirTs.IsZero() { 275 lastBuildTime = time.Now() 276 } else { 277 lastBuildTime = buildDirTs 278 } 279 lastBuildContentGZ = buf.Bytes() 280 281 // log.Printf("GOT TO UPDATE") 282 h.mu.Lock() 283 h.lastBuildTime = lastBuildTime 284 h.lastBuildContentGZ = lastBuildContentGZ 285 h.mu.Unlock() 286 287 } 288 289 serveBuiltFile: 290 291 w.Header().Set("Content-Type", "application/wasm") 292 // w.Header().Set("Last-Modified", lastBuildTime.Format(http.TimeFormat)) // handled by http.ServeContent 293 294 // if client supports gzip response (the usual case), we just set the gzip header and send back 295 if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { 296 w.Header().Set("Content-Encoding", "gzip") 297 w.Header().Set("X-Gzipped-Size", fmt.Sprint(len(lastBuildContentGZ))) 298 http.ServeContent(w, r, h.MainWasmPath, lastBuildTime, bytes.NewReader(lastBuildContentGZ)) 299 return 300 } 301 302 // no gzip, we decompress internally and send it back 303 gzr, _ := gzip.NewReader(bytes.NewReader(lastBuildContentGZ)) 304 _, err = io.Copy(w, gzr) 305 if err != nil { 306 log.Print(err) 307 } 308 return 309 310 } 311 312 func (h *SimpleHandler) serveGoEnvWasmExecJs(w http.ResponseWriter, r *http.Request) { 313 314 b, err := exec.Command("go", "env", "GOROOT").CombinedOutput() 315 if err != nil { 316 http.Error(w, "failed to run `go env GOROOT`: "+err.Error(), 500) 317 return 318 } 319 320 h.wasmExecJsOnce.Do(func() { 321 h.wasmExecJsContent, err = ioutil.ReadFile(filepath.Join(strings.TrimSpace(string(b)), "misc/wasm/wasm_exec.js")) 322 if err != nil { 323 http.Error(w, "failed to run `go env GOROOT`: "+err.Error(), 500) 324 return 325 } 326 h.wasmExecJsTs = time.Now() // hack but whatever for now 327 }) 328 329 if len(h.wasmExecJsContent) == 0 { 330 http.Error(w, "failed to read wasm_exec.js from local Go environment", 500) 331 return 332 } 333 334 w.Header().Set("Content-Type", "text/javascript") 335 http.ServeContent(w, r, "/wasm_exec.js", h.wasmExecJsTs, bytes.NewReader(h.wasmExecJsContent)) 336 } 337 338 // FilteredFileServer is similar to the standard librarie's http.FileServer 339 // but the handler it returns will refuse to serve any files which don't 340 // match the specified regexp pattern after running through path.Clean(). 341 // The idea is to make it easy to serve only specific kinds of 342 // static files from a directory. If pattern does not match a 404 will be returned. 343 // Be sure to include a trailing "$" if you are checking for file extensions, so it 344 // only matches the end of the path, e.g. "[.](css|js)$" 345 func FilteredFileServer(pattern *regexp.Regexp, fs http.FileSystem) http.Handler { 346 347 if pattern == nil { 348 panic(fmt.Errorf("pattern is nil")) 349 } 350 351 if fs == nil { 352 panic(fmt.Errorf("fs is nil")) 353 } 354 355 fserver := http.FileServer(fs) 356 357 ret := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 358 359 p := path.Clean("/" + r.URL.Path) 360 361 if !strings.HasPrefix(p, "/") { // should never happen after Clean above, but just being extra cautious 362 http.NotFound(w, r) 363 return 364 } 365 366 if !pattern.MatchString(p) { 367 http.NotFound(w, r) 368 return 369 } 370 371 // delegate to the regular file-serving behavior 372 fserver.ServeHTTP(w, r) 373 374 }) 375 376 return ret 377 } 378 379 // DefaultIsPageFunc will return true for any request to a path with no file extension. 380 var DefaultIsPageFunc = func(r *http.Request) bool { 381 // anything without a file extension is a page 382 return path.Ext(path.Clean("/"+r.URL.Path)) == "" 383 } 384 385 // DefaultPageTemplateSource a useful default HTML template for serving pages. 386 var DefaultPageTemplateSource = `<!doctype html> 387 <html> 388 <head> 389 {{if .Title}} 390 <title>{{.Title}}</title> 391 {{else}} 392 <title>Vugu Dev - {{.Request.URL.Path}}</title> 393 {{end}} 394 <meta charset="utf-8"/> 395 {{if .MetaTags}}{{range $k, $v := .MetaTags}} 396 <meta name="{{$k}}" content="{{$v}}"/> 397 {{end}}{{end}} 398 {{if .CSSFiles}}{{range $f := .CSSFiles}} 399 <link rel="stylesheet" href="{{$f}}" /> 400 {{end}}{{end}} 401 <script src="https://cdn.jsdelivr.net/npm/text-encoding@0.7.0/lib/encoding.min.js"></script> <!-- MS Edge polyfill --> 402 <script src="/wasm_exec.js"></script> 403 </head> 404 <body> 405 <div id="vugu_mount_point"> 406 {{if .ServerRenderedOutput}}{{.ServerRenderedOutput}}{{else}} 407 <img style="position: absolute; top: 50%; left: 50%;" src="https://cdnjs.cloudflare.com/ajax/libs/galleriffic/2.0.1/css/loader.gif"> 408 {{end}} 409 </div> 410 <script> 411 var wasmSupported = (typeof WebAssembly === "object"); 412 if (wasmSupported) { 413 if (!WebAssembly.instantiateStreaming) { // polyfill 414 WebAssembly.instantiateStreaming = async (resp, importObject) => { 415 const source = await (await resp).arrayBuffer(); 416 return await WebAssembly.instantiate(source, importObject); 417 }; 418 } 419 const go = new Go(); 420 WebAssembly.instantiateStreaming(fetch("/main.wasm"), go.importObject).then((result) => { 421 go.run(result.instance); 422 }); 423 } else { 424 document.getElementById("vugu_mount_point").innerHTML = 'This application requires WebAssembly support. Please upgrade your browser.'; 425 } 426 </script> 427 </body> 428 </html> 429 ` 430 431 // PageHandler executes a Go template and responsds with the page. 432 type PageHandler struct { 433 Template *template.Template 434 TemplateDataFunc func(r *http.Request) interface{} 435 } 436 437 // DefaultStaticData is a map of static things added to the return value of DefaultTemplateDataFunc. 438 // Provides a quick and dirty way to do things like add CSS files to every page. 439 var DefaultStaticData = make(map[string]interface{}, 4) 440 441 // DefaultTemplateDataFunc is the default behavior for making template data. It 442 // returns a map with "Request" set to r and all elements of DefaultStaticData added to it. 443 var DefaultTemplateDataFunc = func(r *http.Request) interface{} { 444 ret := map[string]interface{}{ 445 "Request": r, 446 } 447 for k, v := range DefaultStaticData { 448 ret[k] = v 449 } 450 return ret 451 } 452 453 // ServeHTTP implements http.Handler 454 func (h *PageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 455 456 tmplData := h.TemplateDataFunc(r) 457 if tmplData == nil { 458 http.NotFound(w, r) 459 return 460 } 461 462 err := h.Template.Execute(w, tmplData) 463 if err != nil { 464 log.Printf("Error during simplehttp.PageHandler.Template.Execute: %v", err) 465 } 466 467 } 468 469 // dirTimestamp finds the most recent time stamp associated with files in a folder 470 // TODO: we should look into file watcher stuff, better performance for large trees 471 func dirTimestamp(dir string) (ts time.Time, reterr error) { 472 473 dirf, err := os.Open(dir) 474 if err != nil { 475 return ts, err 476 } 477 defer dirf.Close() 478 479 fis, err := dirf.Readdir(-1) 480 if err != nil { 481 return ts, err 482 } 483 484 for _, fi := range fis { 485 486 if fi.Name() == "." || fi.Name() == ".." { 487 continue 488 } 489 490 // for directories we recurse 491 if fi.IsDir() { 492 dirTs, err := dirTimestamp(filepath.Join(dir, fi.Name())) 493 if err != nil { 494 return ts, err 495 } 496 if dirTs.After(ts) { 497 ts = dirTs 498 } 499 continue 500 } 501 502 // for files check timestamp 503 mt := fi.ModTime() 504 if mt.After(ts) { 505 ts = mt 506 } 507 } 508 509 return 510 }