Browse Source

client/web: precompress assets

Precompress webclient assets with precompress util. This cuts our
css and js build sizes to about 1/3 of non-compressed size. Similar
compression done on tsconnect and adminhttp assets.

Updates #10261

Signed-off-by: Sonia Appasamy <[email protected]>
Sonia Appasamy 2 years ago
parent
commit
e5e5ebda44
3 changed files with 136 additions and 9 deletions
  1. 37 6
      client/web/assets.go
  2. 4 3
      client/web/build/index.html
  3. 95 0
      cmd/build-webclient/build-webclient.go

+ 37 - 6
client/web/assets.go

@@ -4,6 +4,7 @@
 package web
 
 import (
+	"io"
 	"io/fs"
 	"log"
 	"net/http"
@@ -13,10 +14,13 @@ import (
 	"os/exec"
 	"path/filepath"
 	"strings"
+	"time"
 
 	prebuilt "github.com/tailscale/web-client-prebuilt"
 )
 
+var start = time.Now()
+
 func assetsHandler(devMode bool) (_ http.Handler, cleanup func()) {
 	if devMode {
 		// When in dev mode, proxy asset requests to the Vite dev server.
@@ -25,19 +29,46 @@ func assetsHandler(devMode bool) (_ http.Handler, cleanup func()) {
 	}
 
 	fsys := prebuilt.FS()
-	fileserver := http.FileServer(http.FS(fsys))
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		_, err := fs.Stat(fsys, strings.TrimPrefix(r.URL.Path, "/"))
-		if os.IsNotExist(err) {
-			// rewrite request to just fetch /index.html and let
+		path := strings.TrimPrefix(r.URL.Path, "/")
+		f, err := openPrecompressedFile(w, r, path, fsys)
+		if err != nil {
+			// Rewrite request to just fetch index.html and let
 			// the frontend router handle it.
 			r = r.Clone(r.Context())
-			r.URL.Path = "/"
+			path = "index.html"
+			f, err = openPrecompressedFile(w, r, path, fsys)
+		}
+		if f == nil {
+			http.Error(w, err.Error(), http.StatusNotFound)
+			return
 		}
-		fileserver.ServeHTTP(w, r)
+		defer f.Close()
+
+		// fs.File does not claim to implement Seeker, but in practice it does.
+		fSeeker, ok := f.(io.ReadSeeker)
+		if !ok {
+			http.Error(w, "Not seekable", http.StatusInternalServerError)
+			return
+		}
+
+		// Aggressively cache static assets, since we cache-bust our assets with
+		// hashed filenames.
+		w.Header().Set("Cache-Control", "public, max-age=31535996")
+		w.Header().Set("Vary", "Accept-Encoding")
+
+		http.ServeContent(w, r, path, start, fSeeker)
 	}), nil
 }
 
+func openPrecompressedFile(w http.ResponseWriter, r *http.Request, path string, fs fs.FS) (fs.File, error) {
+	if f, err := fs.Open(path + ".gz"); err == nil {
+		w.Header().Set("Content-Encoding", "gzip")
+		return f, nil
+	}
+	return fs.Open(path) // fallback
+}
+
 // startDevServer starts the JS dev server that does on-demand rebuilding
 // and serving of web client JS and CSS resources.
 func startDevServer() (cleanup func()) {

+ 4 - 3
client/web/build/index.html

@@ -6,10 +6,11 @@
     <meta name="viewport" content="width=device-width, initial-scale=1" />
     <link rel="shortcut icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAQAAADZc7J/AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQflAx4QGA4EvmzDAAAA30lEQVRIx2NgGAWMCKa8JKM4A8Ovt88ekyLCDGOoyDBJMjExMbFy8zF8/EKsCAMDE8yAPyIwFps48SJIBpAL4AZwvoSx/r0lXgQpDN58EWL5x/7/H+vL20+JFxluQKVe5b3Ke5V+0kQQCamfoYKBg4GDwUKI8d0BYkWQkrLKewYBKPPDHUFiRaiZkBgmwhj/F5IgggyUJ6i8V3mv0kCayDAAeEsklXqGAgYGhgV3CnGrwVciYSYk0kokhgS44/JxqqFpiYSZbEgskd4dEBRk1GD4wdB5twKXmlHAwMDAAACdEZau06NQUwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMC0wNy0xNVQxNTo1Mzo0MCswMDowMCVXsDIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjAtMDctMTVUMTU6NTM6NDArMDA6MDBUCgiOAAAAAElFTkSuQmCC" />
     
-    <script type="module" crossorigin src="./assets/index-4d1f45ea.js"></script>
-    <link rel="stylesheet" href="./assets/index-8612dca6.css">
+    <link rel="preload" as="font" href="./assets/Inter.var.latin-39e72c07.woff2" type="font/woff2" crossorigin />
+    <script type="module" crossorigin src="./assets/index-fd4af382.js"></script>
+    <link rel="stylesheet" href="./assets/index-218918fa.css">
   </head>
-  <body>
+  <body class="px-2">
     <noscript>
       <p class="mb-2">You need to enable Javascript to access the Tailscale web client.</p>
       <p>If you need any help, feel free to <a href="mailto:[email protected]" class="link">contact us</a>.</p>

+ 95 - 0
cmd/build-webclient/build-webclient.go

@@ -0,0 +1,95 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+// The build-webclient tool generates the static resources needed for the
+// web client (code at client/web).
+//
+// # Running
+//
+// Meant to be invoked from the tailscale/web-client-prebuilt repo when
+// updating the production built web client assets. To run it manually,
+// you can use `./tool/go run ./misc/build-webclient`
+package main
+
+import (
+	"flag"
+	"fmt"
+	"log"
+	"os"
+	"os/exec"
+	"path/filepath"
+
+	"tailscale.com/util/precompress"
+)
+
+var (
+	outDir = flag.String("outDir", "build/", "path to output directory")
+)
+
+func main() {
+	flag.Parse()
+
+	// The toolDir flag is relative to the current working directory,
+	// so we need to resolve it to an absolute path.
+	toolDir, err := filepath.Abs("./tool")
+	if err != nil {
+		log.Fatalf("Cannot resolve tool-dir: %v", err)
+	}
+
+	if err := build(toolDir, "client/web"); err != nil {
+		log.Fatalf("%v", err)
+	}
+}
+
+func build(toolDir, appDir string) error {
+	if err := os.Chdir(appDir); err != nil {
+		return fmt.Errorf("Cannot change cwd: %w", err)
+	}
+
+	if err := yarn(toolDir); err != nil {
+		return fmt.Errorf("install failed: %w", err)
+	}
+
+	if err := yarn(toolDir, "lint"); err != nil {
+		return fmt.Errorf("lint failed: %w", err)
+	}
+
+	if err := yarn(toolDir, "build", "--outDir="+*outDir, "--emptyOutDir"); err != nil {
+		return fmt.Errorf("build failed: %w", err)
+	}
+
+	var compressedFiles []string
+	if err := precompress.PrecompressDir(*outDir, precompress.Options{
+		ProgressFn: func(path string) {
+			log.Printf("Pre-compressing %v\n", path)
+			compressedFiles = append(compressedFiles, path)
+		},
+	}); err != nil {
+		return fmt.Errorf("Cannot precompress: %w", err)
+	}
+
+	// Cleanup pre-compressed files.
+	for _, f := range compressedFiles {
+		if err := os.Remove(f); err != nil {
+			log.Printf("Failed to cleanup %q: %v", f, err)
+		}
+		// Removing intermediate ".br" version, we use ".gz" asset.
+		if err := os.Remove(f + ".br"); err != nil {
+			log.Printf("Failed to cleanup %q: %v", f+".gz", err)
+		}
+	}
+
+	return nil
+}
+
+func yarn(toolDir string, args ...string) error {
+	args = append([]string{"--silent", "--non-interactive"}, args...)
+	return run(filepath.Join(toolDir, "yarn"), args...)
+}
+
+func run(name string, args ...string) error {
+	cmd := exec.Command(name, args...)
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	return cmd.Run()
+}