From b763a12331d318d2dba52fb5b8ed8a407ba28b00 Mon Sep 17 00:00:00 2001 From: Mihai Parparita Date: Tue, 19 Jul 2022 18:04:09 -0700 Subject: [PATCH] cmd/tsconnect: allow building static resources in a different directory When using tsconnect as a module in another repo, we cannot write to the ./dist directory (modules directories are read-only by default - there is a -modcacherw flag for `go get` but we can't count on it). We add a -distdir flag that is honored by both the build and serve commands for where to place output in. Somewhat tedious because esbuild outputs paths relative to the working directory, so we need to do some extra munging to make them relative to the output directory. Signed-off-by: Mihai Parparita --- cmd/tsconnect/build.go | 58 +++++++++++++++++++++++++++++++------- cmd/tsconnect/common.go | 11 +++++++- cmd/tsconnect/serve.go | 56 ++++++++++++++++++++++-------------- cmd/tsconnect/tsconnect.go | 3 +- 4 files changed, 95 insertions(+), 33 deletions(-) diff --git a/cmd/tsconnect/build.go b/cmd/tsconnect/build.go index a42cef3ea..a47979acc 100644 --- a/cmd/tsconnect/build.go +++ b/cmd/tsconnect/build.go @@ -7,11 +7,14 @@ package main import ( "bytes" "compress/gzip" + "encoding/json" + "fmt" "io" "io/fs" "io/ioutil" "log" "os" + "path" "path/filepath" "github.com/andybalholm/brotli" @@ -26,7 +29,7 @@ func runBuild() { } if err := cleanDist(); err != nil { - log.Fatalf("Cannot clean dist/: %v", err) + log.Fatalf("Cannot clean %s: %v", *distDir, err) } buildOptions.Write = true @@ -55,7 +58,11 @@ func runBuild() { } // Preserve build metadata so we can extract hashed file names for serving. - if err := ioutil.WriteFile("./dist/esbuild-metadata.json", []byte(result.Metafile), 0666); err != nil { + metadataBytes, err := fixEsbuildMetadataPaths(result.Metafile) + if err != nil { + log.Fatalf("Cannot fix esbuild metadata paths: %v", err) + } + if err := ioutil.WriteFile(path.Join(*distDir, "/esbuild-metadata.json"), metadataBytes, 0666); err != nil { log.Fatalf("Cannot write metadata: %v", err) } @@ -64,18 +71,48 @@ func runBuild() { } } +// fixEsbuildMetadataPaths re-keys the esbuild metadata file to use paths +// relative to the dist directory (it normally uses paths relative to the cwd, +// which are akward if we're running with a different cwd at serving time). +func fixEsbuildMetadataPaths(metadataStr string) ([]byte, error) { + var metadata EsbuildMetadata + if err := json.Unmarshal([]byte(metadataStr), &metadata); err != nil { + return nil, fmt.Errorf("Cannot parse metadata: %w", err) + } + distAbsPath, err := filepath.Abs(*distDir) + if err != nil { + return nil, fmt.Errorf("Cannot get absolute path from %s: %w", *distDir, err) + } + for outputPath, output := range metadata.Outputs { + outputAbsPath, err := filepath.Abs(outputPath) + if err != nil { + return nil, fmt.Errorf("Cannot get absolute path from %s: %w", outputPath, err) + } + outputRelPath, err := filepath.Rel(distAbsPath, outputAbsPath) + if err != nil { + return nil, fmt.Errorf("Cannot get relative path from %s: %w", outputRelPath, err) + } + delete(metadata.Outputs, outputPath) + metadata.Outputs[outputRelPath] = output + } + return json.Marshal(metadata) +} + // cleanDist removes files from the dist build directory, except the placeholder // one that we keep to make sure Git still creates the directory. func cleanDist() error { - log.Printf("Cleaning dist/...\n") - files, err := os.ReadDir("dist") + log.Printf("Cleaning %s...\n", *distDir) + files, err := os.ReadDir(*distDir) if err != nil { + if os.IsNotExist(err) { + return os.MkdirAll(*distDir, 0755) + } return err } for _, file := range files { if file.Name() != "placeholder" { - if err := os.Remove(filepath.Join("dist", file.Name())); err != nil { + if err := os.Remove(filepath.Join(*distDir, file.Name())); err != nil { return err } } @@ -84,22 +121,23 @@ func cleanDist() error { } func precompressDist() error { - log.Printf("Pre-compressing files in dist/...\n") + log.Printf("Pre-compressing files in %s/...\n", *distDir) var eg errgroup.Group - err := fs.WalkDir(os.DirFS("./"), "dist", func(path string, d fs.DirEntry, err error) error { + err := fs.WalkDir(os.DirFS(*distDir), ".", func(p string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } - if !compressibleExtensions[filepath.Ext(path)] { + if !compressibleExtensions[filepath.Ext(p)] { return nil } - log.Printf("Pre-compressing %v\n", path) + p = path.Join(*distDir, p) + log.Printf("Pre-compressing %v\n", p) eg.Go(func() error { - return precompress(path) + return precompress(p) }) return nil }) diff --git a/cmd/tsconnect/common.go b/cmd/tsconnect/common.go index c9a22f4eb..b77402a44 100644 --- a/cmd/tsconnect/common.go +++ b/cmd/tsconnect/common.go @@ -38,7 +38,7 @@ func commonSetup(dev bool) (*esbuild.BuildOptions, error) { return &esbuild.BuildOptions{ EntryPoints: []string{"src/index.js", "src/index.css"}, Loader: map[string]esbuild.Loader{".wasm": esbuild.LoaderFile}, - Outdir: "./dist", + Outdir: *distDir, Bundle: true, Sourcemap: esbuild.SourceMapLinked, LogLevel: esbuild.LogLevelInfo, @@ -103,3 +103,12 @@ func installJSDeps() error { } return err } + +// EsbuildMetadata is the subset of metadata struct (described by +// https://esbuild.github.io/api/#metafile) that we care about for mapping +// from entry points to hashed file names. +type EsbuildMetadata struct { + Outputs map[string]struct { + EntryPoint string `json:"entryPoint,omitempty"` + } `json:"outputs,omitempty"` +} diff --git a/cmd/tsconnect/serve.go b/cmd/tsconnect/serve.go index 5f5faf8c0..65f14c267 100644 --- a/cmd/tsconnect/serve.go +++ b/cmd/tsconnect/serve.go @@ -11,30 +11,48 @@ import ( "fmt" "io" "io/fs" + "io/ioutil" "log" "net/http" + "os" "path" "time" "tailscale.com/tsweb" ) -//go:embed dist/* index.html +//go:embed index.html var embeddedFS embed.FS +//go:embed dist/* +var embeddedDistFS embed.FS + var serveStartTime = time.Now() func runServe() { mux := http.NewServeMux() - indexBytes, err := generateServeIndex() + var distFS fs.FS + if *distDir == "./dist" { + var err error + distFS, err = fs.Sub(embeddedDistFS, "dist") + if err != nil { + log.Fatalf("Could not drop dist/ prefix from embedded FS: %v", err) + } + } else { + distFS = os.DirFS(*distDir) + } + + indexBytes, err := generateServeIndex(distFS) if err != nil { log.Fatalf("Could not generate index.html: %v", err) } mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.ServeContent(w, r, "index.html", serveStartTime, bytes.NewReader(indexBytes)) })) - mux.Handle("/dist/", http.HandlerFunc(handleServeDist)) + mux.Handle("/dist/", http.StripPrefix("/dist/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handleServeDist(w, r, distFS) + }))) tsweb.Debugger(mux) log.Printf("Listening on %s", *addr) @@ -44,14 +62,19 @@ func runServe() { } } -func generateServeIndex() ([]byte, error) { +func generateServeIndex(distFS fs.FS) ([]byte, error) { log.Printf("Generating index.html...\n") rawIndexBytes, err := embeddedFS.ReadFile("index.html") if err != nil { return nil, fmt.Errorf("Could not read index.html: %w", err) } - esbuildMetadataBytes, err := embeddedFS.ReadFile("dist/esbuild-metadata.json") + esbuildMetadataFile, err := distFS.Open("esbuild-metadata.json") + if err != nil { + return nil, fmt.Errorf("Could not open esbuild-metadata.json: %w", err) + } + defer esbuildMetadataFile.Close() + esbuildMetadataBytes, err := ioutil.ReadAll(esbuildMetadataFile) if err != nil { return nil, fmt.Errorf("Could not read esbuild-metadata.json: %w", err) } @@ -62,7 +85,7 @@ func generateServeIndex() ([]byte, error) { entryPointsToHashedDistPaths := make(map[string]string) for outputPath, output := range esbuildMetadata.Outputs { if output.EntryPoint != "" { - entryPointsToHashedDistPaths[output.EntryPoint] = outputPath + entryPointsToHashedDistPaths[output.EntryPoint] = path.Join("dist", outputPath) } } @@ -77,39 +100,30 @@ func generateServeIndex() ([]byte, error) { return indexBytes, nil } -// EsbuildMetadata is the subset of metadata struct (described by -// https://esbuild.github.io/api/#metafile) that we care about for mapping -// from entry points to hashed file names. -type EsbuildMetadata = struct { - Outputs map[string]struct { - EntryPoint string `json:"entryPoint,omitempty"` - } `json:"outputs,omitempty"` -} - var entryPointsToDefaultDistPaths = map[string]string{ "src/index.css": "dist/index.css", "src/index.js": "dist/index.js", } -func handleServeDist(w http.ResponseWriter, r *http.Request) { - p := r.URL.Path[1:] +func handleServeDist(w http.ResponseWriter, r *http.Request, distFS fs.FS) { + path := r.URL.Path var f fs.File // Prefer pre-compressed versions generated during the build step. if tsweb.AcceptsEncoding(r, "br") { - if brotliFile, err := embeddedFS.Open(p + ".br"); err == nil { + if brotliFile, err := distFS.Open(path + ".br"); err == nil { f = brotliFile w.Header().Set("Content-Encoding", "br") } } if f == nil && tsweb.AcceptsEncoding(r, "gzip") { - if gzipFile, err := embeddedFS.Open(p + ".gz"); err == nil { + if gzipFile, err := distFS.Open(path + ".gz"); err == nil { f = gzipFile w.Header().Set("Content-Encoding", "gzip") } } if f == nil { - if rawFile, err := embeddedFS.Open(r.URL.Path[1:]); err == nil { + if rawFile, err := distFS.Open(path); err == nil { f = rawFile } else { http.Error(w, err.Error(), http.StatusNotFound) @@ -130,5 +144,5 @@ func handleServeDist(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "public, max-age=31535996") w.Header().Set("Vary", "Accept-Encoding") - http.ServeContent(w, r, path.Base(r.URL.Path), serveStartTime, fSeeker) + http.ServeContent(w, r, path, serveStartTime, fSeeker) } diff --git a/cmd/tsconnect/tsconnect.go b/cmd/tsconnect/tsconnect.go index 6beb981ed..d5d152d2f 100644 --- a/cmd/tsconnect/tsconnect.go +++ b/cmd/tsconnect/tsconnect.go @@ -18,7 +18,8 @@ import ( ) var ( - addr = flag.String("addr", ":9090", "address to listen on") + addr = flag.String("addr", ":9090", "address to listen on") + distDir = flag.String("distdir", "./dist", "path of directory to place build output in") ) func main() {