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  }