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