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 }