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 <mihai@tailscale.com>
pull/5105/head
Mihai Parparita 2 years ago committed by Mihai Parparita
parent de2dcda2e0
commit b763a12331

@ -7,11 +7,14 @@ package main
import ( import (
"bytes" "bytes"
"compress/gzip" "compress/gzip"
"encoding/json"
"fmt"
"io" "io"
"io/fs" "io/fs"
"io/ioutil" "io/ioutil"
"log" "log"
"os" "os"
"path"
"path/filepath" "path/filepath"
"github.com/andybalholm/brotli" "github.com/andybalholm/brotli"
@ -26,7 +29,7 @@ func runBuild() {
} }
if err := cleanDist(); err != nil { if err := cleanDist(); err != nil {
log.Fatalf("Cannot clean dist/: %v", err) log.Fatalf("Cannot clean %s: %v", *distDir, err)
} }
buildOptions.Write = true buildOptions.Write = true
@ -55,7 +58,11 @@ func runBuild() {
} }
// Preserve build metadata so we can extract hashed file names for serving. // 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) 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 // cleanDist removes files from the dist build directory, except the placeholder
// one that we keep to make sure Git still creates the directory. // one that we keep to make sure Git still creates the directory.
func cleanDist() error { func cleanDist() error {
log.Printf("Cleaning dist/...\n") log.Printf("Cleaning %s...\n", *distDir)
files, err := os.ReadDir("dist") files, err := os.ReadDir(*distDir)
if err != nil { if err != nil {
if os.IsNotExist(err) {
return os.MkdirAll(*distDir, 0755)
}
return err return err
} }
for _, file := range files { for _, file := range files {
if file.Name() != "placeholder" { 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 return err
} }
} }
@ -84,22 +121,23 @@ func cleanDist() error {
} }
func precompressDist() 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 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 { if err != nil {
return err return err
} }
if d.IsDir() { if d.IsDir() {
return nil return nil
} }
if !compressibleExtensions[filepath.Ext(path)] { if !compressibleExtensions[filepath.Ext(p)] {
return nil 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 { eg.Go(func() error {
return precompress(path) return precompress(p)
}) })
return nil return nil
}) })

@ -38,7 +38,7 @@ func commonSetup(dev bool) (*esbuild.BuildOptions, error) {
return &esbuild.BuildOptions{ return &esbuild.BuildOptions{
EntryPoints: []string{"src/index.js", "src/index.css"}, EntryPoints: []string{"src/index.js", "src/index.css"},
Loader: map[string]esbuild.Loader{".wasm": esbuild.LoaderFile}, Loader: map[string]esbuild.Loader{".wasm": esbuild.LoaderFile},
Outdir: "./dist", Outdir: *distDir,
Bundle: true, Bundle: true,
Sourcemap: esbuild.SourceMapLinked, Sourcemap: esbuild.SourceMapLinked,
LogLevel: esbuild.LogLevelInfo, LogLevel: esbuild.LogLevelInfo,
@ -103,3 +103,12 @@ func installJSDeps() error {
} }
return err 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"`
}

@ -11,30 +11,48 @@ import (
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"io/ioutil"
"log" "log"
"net/http" "net/http"
"os"
"path" "path"
"time" "time"
"tailscale.com/tsweb" "tailscale.com/tsweb"
) )
//go:embed dist/* index.html //go:embed index.html
var embeddedFS embed.FS var embeddedFS embed.FS
//go:embed dist/*
var embeddedDistFS embed.FS
var serveStartTime = time.Now() var serveStartTime = time.Now()
func runServe() { func runServe() {
mux := http.NewServeMux() 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 { if err != nil {
log.Fatalf("Could not generate index.html: %v", err) log.Fatalf("Could not generate index.html: %v", err)
} }
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, "index.html", serveStartTime, bytes.NewReader(indexBytes)) 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) tsweb.Debugger(mux)
log.Printf("Listening on %s", *addr) 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") log.Printf("Generating index.html...\n")
rawIndexBytes, err := embeddedFS.ReadFile("index.html") rawIndexBytes, err := embeddedFS.ReadFile("index.html")
if err != nil { if err != nil {
return nil, fmt.Errorf("Could not read index.html: %w", err) 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 { if err != nil {
return nil, fmt.Errorf("Could not read esbuild-metadata.json: %w", err) 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) entryPointsToHashedDistPaths := make(map[string]string)
for outputPath, output := range esbuildMetadata.Outputs { for outputPath, output := range esbuildMetadata.Outputs {
if output.EntryPoint != "" { 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 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{ var entryPointsToDefaultDistPaths = map[string]string{
"src/index.css": "dist/index.css", "src/index.css": "dist/index.css",
"src/index.js": "dist/index.js", "src/index.js": "dist/index.js",
} }
func handleServeDist(w http.ResponseWriter, r *http.Request) { func handleServeDist(w http.ResponseWriter, r *http.Request, distFS fs.FS) {
p := r.URL.Path[1:] path := r.URL.Path
var f fs.File var f fs.File
// Prefer pre-compressed versions generated during the build step. // Prefer pre-compressed versions generated during the build step.
if tsweb.AcceptsEncoding(r, "br") { 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 f = brotliFile
w.Header().Set("Content-Encoding", "br") w.Header().Set("Content-Encoding", "br")
} }
} }
if f == nil && tsweb.AcceptsEncoding(r, "gzip") { 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 f = gzipFile
w.Header().Set("Content-Encoding", "gzip") w.Header().Set("Content-Encoding", "gzip")
} }
} }
if f == nil { if f == nil {
if rawFile, err := embeddedFS.Open(r.URL.Path[1:]); err == nil { if rawFile, err := distFS.Open(path); err == nil {
f = rawFile f = rawFile
} else { } else {
http.Error(w, err.Error(), http.StatusNotFound) 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("Cache-Control", "public, max-age=31535996")
w.Header().Set("Vary", "Accept-Encoding") w.Header().Set("Vary", "Accept-Encoding")
http.ServeContent(w, r, path.Base(r.URL.Path), serveStartTime, fSeeker) http.ServeContent(w, r, path, serveStartTime, fSeeker)
} }

@ -19,6 +19,7 @@ import (
var ( 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() { func main() {

Loading…
Cancel
Save