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>
This commit is contained in:
Mihai Parparita 2022-07-19 18:04:09 -07:00 committed by Mihai Parparita
parent de2dcda2e0
commit b763a12331
4 changed files with 95 additions and 33 deletions

View File

@ -7,11 +7,14 @@
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
})

View File

@ -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"`
}

View File

@ -11,30 +11,48 @@
"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)
}

View File

@ -18,7 +18,8 @@
)
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() {