Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

assets: embedded asset server #5622

Merged
merged 7 commits into from
Mar 24, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ check-js:
build-js:
cd web && yarn install --frozen-lockfile
cd web && yarn build
cp -r web/build/* pkg/assets/build

test-js:
cd web && yarn install --frozen-lockfile
Expand Down
45 changes: 36 additions & 9 deletions internal/cli/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,11 @@ func provideLogActions() store.LogActionsFlag {

func provideWebMode(b model.TiltBuild) (model.WebMode, error) {
switch webModeFlag {
case model.LocalWebMode, model.ProdWebMode, model.PrecompiledWebMode:
case model.LocalWebMode,
model.ProdWebMode,
model.EmbeddedWebMode,
model.CloudWebMode,
model.PrecompiledWebMode:
return webModeFlag, nil
case model.DefaultWebMode:
// Set prod web mode from an environment variable. Useful for
Expand Down Expand Up @@ -244,21 +248,44 @@ func provideWebURL(webHost model.WebHost, webPort model.WebPort) (model.WebURL,
return model.WebURL(*u), nil
}

func targetMode(mode model.WebMode, embeddedAvailable bool) (model.WebMode, error) {
if (mode == model.EmbeddedWebMode || mode == model.PrecompiledWebMode) && !embeddedAvailable {
return mode, fmt.Errorf("requested %s mode, but assets are not available", string(mode))
}
if mode.IsProd() { // cloud by request, embedded when available, otherwise cloud
if mode != model.CloudWebMode && embeddedAvailable {
mode = model.EmbeddedWebMode
} else if mode == model.ProdWebMode {
mode = model.CloudWebMode
}
} else { // precompiled when available and by request, otherwise local
if mode != model.PrecompiledWebMode {
mode = model.LocalWebMode
}
}
return mode, nil
}

func provideAssetServer(mode model.WebMode, version model.WebVersion) (assets.Server, error) {
if mode == model.ProdWebMode {
return assets.NewProdServer(assets.ProdAssetBucket, version)
s, ok := assets.GetEmbeddedServer()
m, err := targetMode(mode, ok)

if err != nil {
return nil, err
}
if mode == model.PrecompiledWebMode || mode == model.LocalWebMode {

switch m {
case model.EmbeddedWebMode, model.PrecompiledWebMode:
return s, nil
case model.CloudWebMode:
return assets.NewProdServer(assets.ProdAssetBucket, version)
case model.LocalWebMode:
path, err := web.StaticPath()
if err != nil {
return nil, err
}
pkgDir := assets.PackageDir(path)
if mode == model.PrecompiledWebMode {
return assets.NewPrecompiledServer(pkgDir), nil
} else {
return assets.NewDevServer(pkgDir, model.WebDevPort(webDevPort))
}
return assets.NewDevServer(pkgDir, model.WebDevPort(webDevPort))
}
return nil, model.UnrecognizedWebModeError(string(mode))
}
4 changes: 4 additions & 0 deletions pkg/assets/build/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
asset-manifest.json
favicon.ico
index.html
static
5 changes: 5 additions & 0 deletions pkg/assets/build/robots.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# This is a placeholder for the embedded build directory, so that Go has
# something to embed when the build-js assets are not present.
# But also, no robots please.
User-agent: *
Disallow: /
56 changes: 56 additions & 0 deletions pkg/assets/embedded.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package assets

import (
"context"
"embed"
"io/fs"
"net/http"
"strconv"

"github.com/tilt-dev/tilt/pkg/logger"
)

//go:embed build
var build embed.FS

type embeddedServer struct {
http.Handler
}

func GetEmbeddedServer() (Server, bool) {
var assets fs.FS
index, err := build.ReadFile("build/index.html")
if err == nil {
assets, err = fs.Sub(build, "build")
}

if err != nil {
return embeddedServer{}, false
}

return embeddedServer{Handler: serveAssets(assets, index)}, true
}

func (s embeddedServer) Serve(ctx context.Context) error {
logger.Get(ctx).Verbosef("Serving embedded Tilt production web assets")
<-ctx.Done()
return nil
}

func (s embeddedServer) TearDown(ctx context.Context) {
}

func serveAssets(assets fs.FS, index []byte) http.HandlerFunc {
handler := http.FileServer(http.FS(assets))
return func(w http.ResponseWriter, req *http.Request) {
w = cacheAssets(w, req.URL.Path, req.Method)
if isAssetPath(req.URL.Path) {
handler.ServeHTTP(w, req)
} else {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Length", strconv.Itoa(len(index)))
w.WriteHeader(200)
_, _ = w.Write(index)
}
}
}
44 changes: 43 additions & 1 deletion pkg/assets/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ func StripPrefix(prefix string, h http.Handler) http.Handler {
})
}

func isAssetPath(path string) bool {
return strings.HasPrefix(path, "/static/") || path == "/favicon.ico"
}

// Middleware that injects version information into the request.
// We rewrite the URL to contain the version.
//
Expand All @@ -35,6 +39,7 @@ func StripPrefix(prefix string, h http.Handler) http.Handler {
func InferVersion(defaultVersion model.WebVersion, h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origPath := r.URL.Path
w = cacheAssets(w, origPath, r.Method)

if matches := versionRe.FindStringSubmatch(origPath); len(matches) > 1 {
h.ServeHTTP(w, appendPublicPathPrefixForVersion(matches[1], r))
Expand All @@ -45,7 +50,7 @@ func InferVersion(defaultVersion model.WebVersion, h http.Handler) http.Handler
return
}

if !(strings.HasPrefix(origPath, "/static/")) && origPath != "/favicon.ico" {
if !isAssetPath(origPath) {
// redirect everything else to the main entry point.
origPath = "index.html"
}
Expand Down Expand Up @@ -74,3 +79,40 @@ func InferVersion(defaultVersion model.WebVersion, h http.Handler) http.Handler
func appendPublicPathPrefixForVersion(version string, r *http.Request) *http.Request {
return appendPublicPathPrefix(fmt.Sprintf("/%s", version), r)
}

type cacheWriter struct {
writer http.ResponseWriter
assetPath, reqMethod string
}

func (w cacheWriter) Header() http.Header {
return w.writer.Header()
}

func (w cacheWriter) Write(b []byte) (int, error) {
return w.writer.Write(b)
}

func (w cacheWriter) WriteHeader(statusCode int) {
if statusCode == 200 && w.reqMethod == http.MethodGet {
// Set caching headers according to this doc:
// https://create-react-app.dev/docs/production-build/#static-file-caching
//
// Static artifacts are checksummed and can be cached indefinitely
// The main index html page should never be cached.
cacheControl := "no-store, max-age=0"
if isAssetPath(w.assetPath) {
cacheControl = "public, max-age=31536000"
}
w.writer.Header().Set("Cache-Control", cacheControl)
}
w.writer.WriteHeader(statusCode)
}

func cacheAssets(w http.ResponseWriter, assetPath, reqMethod string) http.ResponseWriter {
return cacheWriter{
writer: w,
assetPath: assetPath,
reqMethod: reqMethod,
}
}
69 changes: 0 additions & 69 deletions pkg/assets/precompiled.go

This file was deleted.

11 changes: 0 additions & 11 deletions pkg/assets/prod.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,6 @@ func (s prodServer) fetchFromAssetBucket(w http.ResponseWriter, req *http.Reques
// want to embed other frames.
outres.Header.Del("X-Frame-Options")

// Set caching headers according to this doc:
// https://create-react-app.dev/docs/production-build/#static-file-caching
//
// Static artifacts are checksummed and can be cached indefinitely
// The main index html page should never be cached.
if strings.HasSuffix(u.Path, "index.html") {
outres.Header.Set("Cache-Control", "no-store, max-age=0")
} else {
outres.Header.Set("Cache-Control", "public, max-age=31536000")
}

copyHeader(w.Header(), outres.Header)

resBody, err := ioutil.ReadAll(outres.Body)
Expand Down
18 changes: 16 additions & 2 deletions pkg/model/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,15 @@ const (
// Local webpack server
LocalWebMode WebMode = "local"

// Prod gcloud bucket
// Generic prod build; uses embedded if available otherwise cloud
ProdWebMode WebMode = "prod"

// Production build embedded assets
EmbeddedWebMode WebMode = "embedded"

// Production build assets from cloud bucket
CloudWebMode WebMode = "cloud"

// Precompiled with `make build-js`. This is an experimental mode
// we're playing around with to avoid the cost of webpack startup.
PrecompiledWebMode WebMode = "precompiled"
Expand All @@ -53,6 +59,10 @@ func (m *WebMode) Set(v string) error {
*m = LocalWebMode
case string(ProdWebMode):
*m = ProdWebMode
case string(EmbeddedWebMode):
*m = EmbeddedWebMode
case string(CloudWebMode):
*m = CloudWebMode
default:
return UnrecognizedWebModeError(v)
}
Expand All @@ -63,9 +73,13 @@ func (m *WebMode) Type() string {
return "WebMode"
}

func (m WebMode) IsProd() bool {
return m == ProdWebMode || m == EmbeddedWebMode || m == CloudWebMode
}

func UnrecognizedWebModeError(v string) error {
return fmt.Errorf("Unrecognized web mode: %s. Allowed values: %s", v, []WebMode{
DefaultWebMode, LocalWebMode, ProdWebMode, PrecompiledWebMode,
DefaultWebMode, LocalWebMode, ProdWebMode, EmbeddedWebMode, CloudWebMode, PrecompiledWebMode,
})
}

Expand Down
5 changes: 2 additions & 3 deletions scripts/upload-assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,8 @@
print("Then try uploading assets again.")
sys.exit(1)

os.chdir("web")
subprocess.check_call(["yarn", "install"])
e = os.environ.copy()
e["CI"] = "false"
subprocess.check_call(["yarn", "run", "build"], env=e)
subprocess.check_call(["make", "build-js"], env=e)
os.chdir("web")
subprocess.check_call(["gsutil", "-m", "cp", "-r", "build", "gs://tilt-static-assets/%s" % version])