-
Notifications
You must be signed in to change notification settings - Fork 3
/
path.go
160 lines (143 loc) · 4.68 KB
/
path.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
package main
import (
"encoding/hex"
"log"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"sync"
esbuild "github.com/evanw/esbuild/pkg/api"
"github.com/gorilla/mux"
"github.com/rjeczalik/notify"
)
// SymmetricPath describes a local filesystem path that can be accessed via
// HTTP through a URL prefix.
type SymmetricPath struct {
LocalPath string // must end with a slash
URLPrefix string // must start *and* end with a slash
}
// HostedPath adds an HTTP file server with version-based URL suffixes to
// SymmetricPath.
type HostedPath struct {
SymmetricPath
srv http.Handler
fileToHash sync.Map
depToSource sync.Map
}
// NewHostedPath sets up a new HostedPath instance.
func NewHostedPath(LocalPath string, URLPrefix string) *HostedPath {
absoluteLocalPath, err := filepath.Abs(LocalPath)
FatalIf(err)
dir := http.FileServer(http.Dir(absoluteLocalPath))
ret := &HostedPath{
SymmetricPath: SymmetricPath{
LocalPath: (absoluteLocalPath + "/"),
URLPrefix: URLPrefix,
},
srv: http.HandlerFunc(func(wr http.ResponseWriter, req *http.Request) {
if len(req.URL.RawQuery) > 0 {
wr.Header().Set("Cache-Control", "max-age=31536000, immutable")
}
dir.ServeHTTP(wr, req)
}),
}
go ret.watch()
return ret
}
// watch watches the LocalPath for file changes in order to purge hashes as
// necessary.
func (hp *HostedPath) watch() {
// Make the channel buffered to ensure no event is dropped. Notify will
// drop an event if the receiver is not able to keep up the sending pace.
c := make(chan notify.EventInfo, 1)
err := notify.Watch(hp.LocalPath+"...", c, notify.Write)
FatalIf(err)
for e := range c {
fn, err := filepath.Rel(hp.LocalPath, e.Path())
FatalIf(err)
// At least on Windows, I always get multiple events each time I save a
// file. At the first event, the file is still locked for concurrent
// reads, so we can't immediately rehash it. So, let's just delete the
// old hash, and let the new one be generated on demand…
if _, deleted := hp.fileToHash.LoadAndDelete(fn); deleted {
log.Printf("%s: \"%s\" changed", hp.LocalPath, fn)
if dep, ok := hp.depToSource.Load(fn); ok {
hp.fileToHash.Delete(dep)
}
}
}
}
// Server returns hp's file serving handler, e.g. to be re-used elsewhere.
func (hp *HostedPath) Server() http.Handler {
return hp.srv
}
// RegisterFileServer registers a HTTP route on the given router at hp's
// URLPrefix, serving any local files in hp's LocalPath.
func (hp *HostedPath) RegisterFileServer(r *mux.Router) {
stripped := http.StripPrefix(hp.URLPrefix, hp.srv)
r.PathPrefix(hp.URLPrefix).Handler(stripped)
}
var esbuildLoader = map[string]esbuild.Loader{
".scss": esbuild.LoaderGlobalCSS,
}
// esbuild runs the esbuild Build API with common settings on the given file.
func (hp *HostedPath) esbuild(outBasename, inBasename string) (deps []string) {
inFN := filepath.Join(hp.LocalPath, inBasename)
_, err := os.Stat(inFN)
if err == nil {
outFN := filepath.Join(hp.LocalPath, outBasename)
os.Remove(outFN)
deps = []string{inBasename}
result := esbuild.Build(esbuild.BuildOptions{
LogLevel: esbuild.LogLevelWarning,
EntryPoints: []string{inFN},
Outfile: outFN,
Loader: esbuildLoader,
MinifyWhitespace: true,
MinifyIdentifiers: true,
MinifySyntax: true,
Sourcemap: esbuild.SourceMapLinked,
Supported: map[string]bool{"nesting": false},
Write: true,
})
if len(result.Errors) > 0 {
// Return [deps] without the target file, since it doesn't exist.
return deps
}
}
return append(deps, outBasename)
}
// buildFile runs any necessary build step to generate fn. Returns an array of
// all existing files that should invalidate fn if they are changed.
func (hp *HostedPath) buildFile(fn string) (deps []string) {
switch strings.ToLower(path.Ext(fn)) {
case ".js":
// Transpile TypeScript
return hp.esbuild(fn, (strings.TrimSuffix(fn, ".js") + ".ts"))
case ".css":
// Minify and polyfill CSS
return hp.esbuild(fn, (strings.TrimSuffix(fn, ".css") + ".scss"))
}
return append(deps, fn)
}
// VersionQueryFor returns the current hash of fn as a query string suffix.
func (hp *HostedPath) VersionQueryFor(fn string) string {
hash, ok := hp.fileToHash.Load(fn)
if !ok {
deps := hp.buildFile(fn)
for _, dep := range deps {
hp.depToSource.Store(dep, fn)
fullHash := CryptHashOfFile(hp.LocalPath + dep)
hash = hex.EncodeToString(fullHash[:4])
hp.fileToHash.Store(dep, hash)
}
}
return "?" + hash.(string)
}
// VersionURLFor returns the full URL of fn, with a version-based query string
// suffix.
func (hp *HostedPath) VersionURLFor(fn string) string {
return (hp.URLPrefix + fn + hp.VersionQueryFor(fn))
}