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  }