-
Notifications
You must be signed in to change notification settings - Fork 1
/
serve.go
238 lines (206 loc) · 7.08 KB
/
serve.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
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
package commands
import (
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
"github.com/alecthomas/kong"
"github.com/facundoolano/jorge/config"
"github.com/facundoolano/jorge/site"
"github.com/fsnotify/fsnotify"
)
type Serve struct {
ProjectDir string `arg:"" name:"path" optional:"" default:"." help:"Path to the website project to serve."`
Host string `short:"H" default:"localhost" help:"Host to run the server on."`
Port int `short:"p" default:"4001" help:"Port to run the server on."`
NoReload bool `help:"Disable live reloading."`
}
func (cmd *Serve) Run(ctx *kong.Context) error {
config, err := config.LoadDev(cmd.ProjectDir, cmd.Host, cmd.Port, !cmd.NoReload)
if err != nil {
return err
}
if _, err := os.Stat(config.SrcDir); os.IsNotExist(err) {
return fmt.Errorf("missing src directory")
}
// watch for changes in src and layouts, and trigger a rebuild
watcher, broker, err := runWatcher(config)
if err != nil {
return err
}
defer watcher.Close()
// serve the target dir with a file server
fs := http.FileServer(HTMLFileSystem{http.Dir(config.TargetDir)})
http.Handle("/", fs)
if config.LiveReload {
// handle client requests to listen to server-sent events
http.Handle("/_events/", makeServerEventsHandler(broker))
}
addr := fmt.Sprintf("%s:%d", config.ServerHost, config.ServerPort)
return http.ListenAndServe(addr, nil)
}
// Return an http.HandlerFunc that establishes a server-sent event stream with clients,
// subscribes to site rebuild events received through the given event broker
// and forwards them to the client.
func makeServerEventsHandler(broker *EventBroker) http.HandlerFunc {
return func(res http.ResponseWriter, req *http.Request) {
res.Header().Set("Content-Type", "text/event-stream")
res.Header().Set("Connection", "keep-alive")
res.Header().Set("Cache-Control", "no-cache")
res.Header().Set("Access-Control-Allow-Origin", "*")
id, events := broker.subscribe()
for {
select {
case <-events:
// send an event to the connected client.
// data\n\n just means send an empty, unnamed event
// since we only need to support the single reload operation.
fmt.Fprint(res, "retry: 1000\n")
fmt.Fprint(res, "data\n\n")
res.(http.Flusher).Flush()
case <-req.Context().Done():
broker.unsubscribe(id)
return
}
}
}
}
// Sets up a watcher that will publish changes in the site source files
// to the returned event broker.
func runWatcher(config *config.Config) (*fsnotify.Watcher, *EventBroker, error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, nil, err
}
broker := newEventBroker()
// the rebuild is handled after some delay to prevent bursts of events to trigger repeated rebuilds
// which can cause the browser to refresh while another unfinished build is in progress (refreshing to
// a missing file). The initial build is done immediately.
rebuildAfter := time.AfterFunc(0, func() {
rebuildSite(config, watcher, broker)
})
go func() {
for event := range watcher.Events {
// chmod events are noisy, ignore them.
// Also ignore dot file events, which are usually spurious (e.g .DS_Store, emacs temp files)
isDotFile := strings.HasPrefix(filepath.Base(event.Name), ".")
if event.Has(fsnotify.Chmod) || isDotFile {
continue
}
// Schedule a rebuild to trigger after a delay. If there was another one pending
// it will be canceled.
fmt.Printf("\nfile %s changed\n", event.Name)
rebuildAfter.Stop()
rebuildAfter.Reset(100 * time.Millisecond)
}
}()
return watcher, broker, err
}
// React to source file change events by re-watching the source directories,
// rebuilding the site and publishing a rebuild event to clients.
func rebuildSite(config *config.Config, watcher *fsnotify.Watcher, broker *EventBroker) {
fmt.Printf("building site\n")
// since new nested directories could be triggering this change, and we need to watch those too
// and since re-watching files is a noop, I just re-add the entire src everytime there's a change
if err := watchProjectFiles(watcher, config); err != nil {
fmt.Println("couldn't add watchers:", err)
}
if err := site.Build(*config); err != nil {
fmt.Println("build error:", err)
return
}
broker.publish("rebuild")
fmt.Println("done\nserving at", config.SiteUrl)
}
// Configure the given watcher to notify for changes in the project source files
func watchProjectFiles(watcher *fsnotify.Watcher, config *config.Config) error {
watcher.Add(config.LayoutsDir)
watcher.Add(config.DataDir)
watcher.Add(config.IncludesDir)
// fsnotify watches all files within a dir, but non recursively
// this walks through the src dir and adds watches for each found directory
return filepath.WalkDir(config.SrcDir, func(path string, entry fs.DirEntry, err error) error {
if entry.IsDir() {
watcher.Add(path)
}
return nil
})
}
// Tweaks the http file system to construct a server that hides the .html suffix from requests.
// Based on https://stackoverflow.com/a/57281956/993769
type HTMLFileSystem struct {
dirFS http.Dir
}
func (htmlFS HTMLFileSystem) Open(name string) (http.File, error) {
// Try name as supplied
f, err := htmlFS.dirFS.Open(name)
if os.IsNotExist(err) {
// Not found, try with .html
if f, err := htmlFS.dirFS.Open(name + ".html"); err == nil {
return f, nil
}
}
return f, err
}
// The event broker allows the file watcher to publish site rebuild events
// and register http clients to listen for them, in order to trigger browser refresh
// events after the the site has been rebuilt.
type EventBroker struct {
inEvents chan string
inSubscriptions chan Subscription
subscribers map[uint64]chan string
idgen atomic.Uint64
}
type Subscription struct {
id uint64
outEvents chan string
}
func newEventBroker() *EventBroker {
broker := EventBroker{
inEvents: make(chan string),
inSubscriptions: make(chan Subscription),
subscribers: map[uint64]chan string{},
}
go func() {
for {
select {
case msg := <-broker.inSubscriptions:
if msg.outEvents != nil {
// subscribe
broker.subscribers[msg.id] = msg.outEvents
} else {
// unsubscribe
close(broker.subscribers[msg.id])
delete(broker.subscribers, msg.id)
}
case msg := <-broker.inEvents:
// send the event to all the subscribers
for _, outEvents := range broker.subscribers {
outEvents <- msg
}
}
}
}()
return &broker
}
// Adds a subscription to this broker events, returning a subscriber id
// (useful for unsubscribing later) and a channel where events will be delivered.
func (broker *EventBroker) subscribe() (uint64, <-chan string) {
id := broker.idgen.Add(1)
outEvents := make(chan string)
broker.inSubscriptions <- Subscription{id, outEvents}
return id, outEvents
}
// Remove the subscriber with the given id from the broker,
// closing its associated channel.
func (broker *EventBroker) unsubscribe(id uint64) {
broker.inSubscriptions <- Subscription{id: id, outEvents: nil}
}
// Publish an event to all the broker subscribers.
func (broker *EventBroker) publish(event string) {
broker.inEvents <- event
}