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() {