github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/server/static.go (about)

     1  // Copyright 2022 Daniel Erat.
     2  // All rights reserved.
     3  
     4  package main
     5  
     6  import (
     7  	"bufio"
     8  	"bytes"
     9  	"io"
    10  	"io/ioutil"
    11  	"os"
    12  	"path/filepath"
    13  	"strings"
    14  	"sync"
    15  
    16  	"github.com/derat/nup/server/esbuild"
    17  
    18  	"github.com/evanw/esbuild/pkg/api"
    19  
    20  	"github.com/tdewolff/minify/v2"
    21  	"github.com/tdewolff/minify/v2/css"
    22  	"github.com/tdewolff/minify/v2/html"
    23  	"github.com/tdewolff/minify/v2/js"
    24  	"github.com/tdewolff/minify/v2/json"
    25  	"github.com/tdewolff/minify/v2/svg"
    26  )
    27  
    28  const (
    29  	staticDir = "web" // directory containing static files, relative to app
    30  
    31  	bundleFile       = "bundle.js" // generated file containing bundled JS
    32  	bundleEntryPoint = "index.ts"  // entry point used to create bundle
    33  
    34  	indexFile         = "index.html" // file that initially loads JS
    35  	scriptPlaceholder = "{{SCRIPT}}" // script placeholder in indexFile
    36  )
    37  
    38  const (
    39  	cssType  = "text/css"
    40  	htmlType = "text/html"
    41  	jsType   = "application/javascript"
    42  	jsonType = "application/json"
    43  	svgType  = "image/svg+xml"
    44  	tsType   = "application/typescript"
    45  )
    46  
    47  var extTypes = map[string]string{
    48  	".css":  cssType,
    49  	".html": htmlType,
    50  	".js":   jsType,
    51  	".json": jsonType,
    52  	".svg":  svgType,
    53  	".ts":   tsType,
    54  }
    55  
    56  var minifier *minify.M
    57  
    58  func init() {
    59  	minifier = minify.New()
    60  	minifier.AddFunc(cssType, css.Minify)
    61  	minifier.Add(htmlType, &html.Minifier{
    62  		KeepDefaultAttrVals: true, // avoid breaking "input[type='text']" selectors
    63  	})
    64  	// JS and TS files are minified and bundled by esbuild, but this is used to
    65  	// minify some trivial JS inlined in index.html.
    66  	minifier.AddFunc(jsType, js.Minify)
    67  	minifier.AddFunc(jsonType, json.Minify)
    68  	minifier.AddFunc(svgType, svg.Minify)
    69  }
    70  
    71  // staticFiles maps from a staticKey to a []byte containing the content of
    72  // the previously-loaded and -processed static file.
    73  var staticFiles sync.Map
    74  
    75  // staticKey contains arguments passed to getStaticFile.
    76  type staticKey struct {
    77  	path   string // relative request path (e.g. "index.html")
    78  	minify bool
    79  }
    80  
    81  // getStaticFile returns the contents of the file at the specified path
    82  // (without a leading slash) within staticDir.
    83  //
    84  // Files are transformed in various ways:
    85  //  - If minify is true, the returned file is minified.
    86  //  - If indexFile is requested, scriptPlaceholder is replaced by bundleFile
    87  //    if minify is true or by the JS version of bundleEntryPoint otherwise.
    88  //  - If bundleFile is requested, bundleEntryPoint and all of its dependencies
    89  //    are returned as a single ES module. The code is minified regardless of
    90  //    whether minification was requested.
    91  //  - If a nonexistent .js file is requested, its .ts counterpart is transpiled
    92  //    and returned.
    93  func getStaticFile(p string, minify bool) ([]byte, error) {
    94  	key := staticKey{p, minify}
    95  	if b, ok := staticFiles.Load(key); ok {
    96  		return b.([]byte), nil
    97  	}
    98  
    99  	// The bundle file doesn't actually exist on-disk, so handle it first.
   100  	if p == bundleFile {
   101  		js, sourceMap, err := buildBundle()
   102  		if err != nil {
   103  			return nil, err
   104  		}
   105  		staticFiles.Store(key, js)
   106  		staticFiles.Store(staticKey{p + esbuild.SourceMapExt, minify}, sourceMap)
   107  		return js, nil
   108  	}
   109  
   110  	fp := filepath.Join(staticDir, p)
   111  	if !strings.HasPrefix(fp, staticDir+"/") {
   112  		return nil, os.ErrNotExist
   113  	}
   114  
   115  	ext := filepath.Ext(fp)
   116  	ctype := extTypes[ext]
   117  	f, err := os.Open(fp)
   118  	if os.IsNotExist(err) && ext == ".js" {
   119  		// If a .js file doesn't exist, try to read its .ts counterpart.
   120  		f, err = os.Open(replaceSuffix(fp, ".js", ".ts"))
   121  		ctype = tsType
   122  	}
   123  	if err != nil {
   124  		return nil, err
   125  	}
   126  	defer f.Close()
   127  
   128  	var b []byte
   129  	if minify && ctype != "" {
   130  		if b, err = minifyAndTransformData(f, ctype); err != nil {
   131  			return nil, err
   132  		}
   133  	} else {
   134  		if b, err = ioutil.ReadAll(f); err != nil {
   135  			return nil, err
   136  		}
   137  		// Transform TypeScript code to JavaScript. esbuild also appears to do some
   138  		// degree of minimization no matter what: blank lines and comments are stripped.
   139  		if ctype == tsType {
   140  			base := filepath.Base(f.Name())
   141  			if b, err = esbuild.Transform(b, api.LoaderTS, false, base); err != nil {
   142  				return nil, err
   143  			}
   144  		}
   145  	}
   146  
   147  	// Make index.html load the appropriate script depending on whether minification is enabled.
   148  	if p == indexFile {
   149  		ep := replaceSuffix(bundleEntryPoint, ".ts", ".js")
   150  		if minify {
   151  			ep = bundleFile
   152  		}
   153  		b = bytes.Replace(b, []byte(scriptPlaceholder), []byte(ep), 1)
   154  	}
   155  
   156  	staticFiles.Store(key, b)
   157  	return b, nil
   158  }
   159  
   160  // replaceSuffix returns s with the specified suffix replaced.
   161  // If s doesn't end in from, it is returned unchanged.
   162  func replaceSuffix(s string, from, to string) string {
   163  	if !strings.HasSuffix(s, from) {
   164  		return s
   165  	}
   166  	return strings.TrimSuffix(s, from) + to
   167  }
   168  
   169  // minifyData reads from r and returns a minified version of its data.
   170  // ctype should be a value from extTypes, e.g. cssType or htmlType.
   171  // If ctype is jsType or tsType, the data will also be transformed to an ES module.
   172  func minifyAndTransformData(r io.Reader, ctype string) ([]byte, error) {
   173  	switch ctype {
   174  	case jsType, tsType:
   175  		// Minify embedded HTML and CSS and then use esbuild to minify and transform the code.
   176  		b, err := minifyTemplates(r)
   177  		if err != nil {
   178  			return nil, err
   179  		}
   180  		loader := api.LoaderJS
   181  		if ctype == tsType {
   182  			loader = api.LoaderTS
   183  		}
   184  		var fn string
   185  		if f, ok := r.(*os.File); ok {
   186  			fn = filepath.Base(f.Name())
   187  		}
   188  		return esbuild.Transform(b, loader, true, fn)
   189  	default:
   190  		var b bytes.Buffer
   191  		err := minifier.Minify(ctype, &b, r)
   192  		return b.Bytes(), err
   193  	}
   194  }
   195  
   196  // minifyTemplates looks for createTemplate() and replaceSync() calls in the
   197  // supplied JavaScript code and minifies the contents as HTML and CSS, respectively.
   198  //
   199  // The opening "createTemplate(`" or ".replaceSync(`" must appear at the end of a
   200  // line and the closing "`);" must appear on a line by itself.
   201  func minifyTemplates(r io.Reader) ([]byte, error) {
   202  	var b bytes.Buffer
   203  	var inHTML, inCSS bool
   204  	var quoted string
   205  	sc := bufio.NewScanner(r)
   206  	for sc.Scan() {
   207  		ln := sc.Text()
   208  		inQuoted := inHTML || inCSS
   209  		switch {
   210  		case !inQuoted && strings.HasSuffix(ln, "createTemplate(`"):
   211  			io.WriteString(&b, ln) // omit trailing newline
   212  			inHTML = true
   213  		case !inQuoted && strings.HasSuffix(ln, ".replaceSync(`"):
   214  			io.WriteString(&b, ln) // omit trailing newline
   215  			inCSS = true
   216  		case !inQuoted:
   217  			io.WriteString(&b, ln+"\n")
   218  		case inQuoted && ln == "`);":
   219  			t := htmlType
   220  			if inCSS {
   221  				t = cssType
   222  			}
   223  			min, err := minifier.String(t, quoted)
   224  			if err != nil {
   225  				return nil, err
   226  			}
   227  			io.WriteString(&b, min+ln+"\n")
   228  			inHTML = false
   229  			inCSS = false
   230  			quoted = ""
   231  		case inQuoted:
   232  			quoted += ln + "\n"
   233  		}
   234  	}
   235  	return b.Bytes(), sc.Err()
   236  }
   237  
   238  // buildBundle builds a single minified bundle file consisting of bundleEntryPoint
   239  // and all of its imports.
   240  func buildBundle() (js, sourceMap []byte, err error) {
   241  	// Write all the (possibly minified) .js and .ts files to a temp dir for esbuild.
   242  	// I think it'd be possible to write an esbuild plugin that returns these
   243  	// files from memory, but the plugin API is still experimental and we only
   244  	// hit this code path once per instance.
   245  	td, err := ioutil.TempDir("", "nup_bundle.*")
   246  	if err != nil {
   247  		return nil, nil, err
   248  	}
   249  	defer os.RemoveAll(td)
   250  
   251  	paths, err := filepath.Glob(filepath.Join(staticDir, "*.[jt]s"))
   252  	if err != nil {
   253  		return nil, nil, err
   254  	}
   255  	for _, p := range paths {
   256  		// Don't minify or transform the code yet (esbuild.Bundle will do that later),
   257  		// but minify embedded HTML and CSS.
   258  		base := filepath.Base(p)
   259  		b, err := getStaticFile(base, false /* minify */)
   260  		if err != nil {
   261  			return nil, nil, err
   262  		}
   263  		if b, err = minifyTemplates(bytes.NewReader(b)); err != nil {
   264  			return nil, nil, err
   265  		}
   266  		if err := ioutil.WriteFile(filepath.Join(td, base), b, 0644); err != nil {
   267  			return nil, nil, err
   268  		}
   269  	}
   270  
   271  	return esbuild.Bundle(td, []string{bundleEntryPoint}, bundleFile, true /* minify */)
   272  }