github.com/brandur/modulir@v0.0.0-20240305213423-94ee82929cbd/modulir.go (about) 1 package modulir 2 3 import ( 4 "context" 5 "fmt" 6 "net/http" 7 "os" 8 "os/signal" 9 "sync" 10 "time" 11 12 "github.com/fsnotify/fsnotify" 13 "golang.org/x/sys/unix" 14 "golang.org/x/xerrors" 15 ) 16 17 ////////////////////////////////////////////////////////////////////////////// 18 // 19 // 20 // 21 // Public 22 // 23 // 24 // 25 ////////////////////////////////////////////////////////////////////////////// 26 27 // Config contains configuration. 28 type Config struct { 29 // Concurrency is the number of concurrent workers to run during the build 30 // step. 31 // 32 // Defaults to 10. 33 Concurrency int 34 35 // Log specifies a logger to use. 36 // 37 // Defaults to an instance of Logger running at informational level. 38 Log LoggerInterface 39 40 // LogColor specifies whether messages sent to Log should be color. You may 41 // want to set to true if you know output is going to a terminal. 42 // 43 // Defaults to false. 44 LogColor bool 45 46 // Port specifies the port on which to serve content from TargetDir over 47 // HTTP. 48 // 49 // Defaults to not running if left unset. 50 Port int 51 52 // SourceDir is the directory containing source files. 53 // 54 // Defaults to ".". 55 SourceDir string 56 57 // TargetDir is the directory where the site will be built to. 58 // 59 // Defaults to "./public". 60 TargetDir string 61 62 // Websocket indicates that Modulir should be started in development 63 // mode with a websocket that provides features like live reload. 64 // 65 // Defaults to false. 66 Websocket bool 67 } 68 69 // Build is one of the main entry points to the program. Call this to build 70 // only one time. 71 func Build(config *Config, f func(*Context) []error) { 72 var buildCompleteMu sync.Mutex 73 buildComplete := sync.NewCond(&buildCompleteMu) 74 finish := make(chan struct{}, 1) 75 76 // Signal the build loop to finish immediately 77 finish <- struct{}{} 78 79 c := initContext(config, nil) 80 ensureTargetDir(c) 81 82 success := build(c, f, finish, buildComplete) 83 if !success { 84 os.Exit(1) 85 } 86 } 87 88 // BuildLoop is one of the main entry points to the program. Call this to build 89 // in a perpetual loop. 90 func BuildLoop(config *Config, f func(*Context) []error) { 91 var buildCompleteMu sync.Mutex 92 buildComplete := sync.NewCond(&buildCompleteMu) 93 finish := make(chan struct{}, 1) 94 95 watcher, err := fsnotify.NewWatcher() 96 if err != nil { 97 exitWithError(xerrors.Errorf("error starting watcher: %w", err)) 98 } 99 defer watcher.Close() 100 101 c := initContext(config, watcher) 102 ensureTargetDir(c) 103 104 // Serve HTTP 105 var server *http.Server 106 go func() { 107 server = startServingTargetDirHTTP(c, buildComplete) 108 }() 109 110 // Run the build loop. Loops forever until receiving on finish. 111 go build(c, f, finish, buildComplete) 112 113 // Listen for signals. Modulir will gracefully exit and re-exec itself upon 114 // receipt of USR2. 115 signals := make(chan os.Signal, 1024) 116 signal.Notify(signals, unix.SIGUSR2) 117 for { 118 s := <-signals 119 if s == unix.SIGUSR2 { 120 shutdownAndExec(c, finish, watcher, server) 121 } 122 } 123 } 124 125 ////////////////////////////////////////////////////////////////////////////// 126 // 127 // 128 // 129 // Private 130 // 131 // 132 // 133 ////////////////////////////////////////////////////////////////////////////// 134 135 // Runs an infinite built loop until a signal is received over the `finish` 136 // channel. 137 // 138 // Returns true of the last build was successful and false otherwise. 139 func build(c *Context, f func(*Context) []error, 140 finish chan struct{}, buildComplete *sync.Cond, 141 ) bool { 142 rebuild := make(chan map[string]struct{}) 143 rebuildDone := make(chan struct{}) 144 145 if c.Watcher != nil { 146 go watchChanges(c, c.Watcher.Events, c.Watcher.Errors, 147 rebuild, rebuildDone) 148 } 149 150 // Paths that changed on the last loop (as discovered via fsnotify). If 151 // set, we go into quick build mode with only these paths activated, and 152 // unset them afterwards. This saves us doing lots of checks on the 153 // filesystem and makes jobs much faster to run. 154 var lastChangedSources map[string]struct{} 155 156 for { 157 c.Log.Debugf("Start loop") 158 c.ResetBuild() 159 c.StartRound() 160 161 if lastChangedSources != nil { 162 c.QuickPaths = lastChangedSources 163 } 164 165 errors := f(c) 166 167 var lastRoundErrors []error 168 169 // Do one wait round as the build loop might not have waited on its 170 // last phase, but only bother if it looks like any jobs were enqueued. 171 if len(c.Pool.Jobs) > 0 { 172 lastRoundErrors = c.Wait() 173 } 174 175 // Context's Wait restarts the pool, so wait on that one more time to 176 // shut it back down. 177 c.Pool.Wait() 178 179 buildDuration := time.Since(c.Stats.Start) 180 181 if lastRoundErrors != nil { 182 errors = append(errors, lastRoundErrors...) 183 } 184 185 c.Pool.LogErrorsSlice(errors) 186 c.Pool.LogSlowestSlice(c.Stats.JobsExecuted) 187 188 success := len(c.Stats.JobsErrored) == 0 189 190 c.Log.Infof( 191 c.colorizer.Bold(colorByStatus(c, "Built site in %s", success)).String()+ 192 " (loop took %v; total non-parallel time %v)", 193 buildDuration.Truncate(100*time.Microsecond), 194 c.Stats.LoopDuration.Truncate(100*time.Microsecond), 195 calculateTotalDuration(c.Stats.JobsExecuted).Truncate(100*time.Microsecond), 196 ) 197 c.Log.Infof( 198 "%v of %v job(s) did work in %v round(s); "+ 199 c.colorizer.Bold(colorByStatus(c, "%v errored", success)).String(), 200 len(c.Stats.JobsExecuted), c.Stats.NumJobs, c.Stats.NumRounds, len(c.Stats.JobsErrored), 201 ) 202 203 c.QuickPaths = nil 204 205 buildComplete.Broadcast() 206 207 if c.FirstRun { 208 c.FirstRun = false 209 } else { 210 rebuildDone <- struct{}{} 211 } 212 213 select { 214 case <-finish: 215 c.Log.Infof("Build loop detected finish signal; stopping") 216 return len(errors) < 1 217 218 case lastChangedSources = <-rebuild: 219 c.Log.Infof("Build loop detected change on %v; rebuilding", 220 mapKeys(lastChangedSources)) 221 } 222 } 223 } 224 225 func colorByStatus(c *Context, s string, success bool) string { 226 if success { 227 return c.colorizer.Green(s).String() 228 } 229 230 return c.colorizer.Red(s).String() 231 } 232 233 // Calculates the total duration given a set of jobs. 234 func calculateTotalDuration(jobs []*Job) time.Duration { 235 var totalTime time.Duration 236 for _, job := range jobs { 237 totalTime += job.Duration 238 } 239 return totalTime 240 } 241 242 // Ensures that the configured TargetDir exists. We want to do this early (i.e. 243 // before the build loop) so that we can start the HTTP server right away 244 // instead of waiting for a build. 245 func ensureTargetDir(c *Context) { 246 if err := os.MkdirAll(c.TargetDir, 0o755); err != nil { 247 exitWithError(xerrors.Errorf("error creating target directory: %w", err)) 248 } 249 } 250 251 // Exits with status 1 after printing the given error to stderr. 252 func exitWithError(err error) { 253 fmt.Fprintf(os.Stderr, "error: %v\n", err) 254 os.Exit(1) 255 } 256 257 // Takes a Modulir configuration and initializes it with defaults for any 258 // properties that weren't expressly filled in. 259 func initConfigDefaults(config *Config) *Config { 260 if config == nil { 261 config = &Config{} 262 } 263 264 if config.Concurrency <= 0 { 265 config.Concurrency = 50 266 } 267 268 if config.Log == nil { 269 config.Log = &Logger{Level: LevelInfo} 270 } 271 272 if config.SourceDir == "" { 273 config.SourceDir = "." 274 } 275 276 if config.TargetDir == "" { 277 config.TargetDir = "./public" 278 } 279 280 return config 281 } 282 283 // Initializes a new Modulir context from the given configuration. 284 func initContext(config *Config, watcher *fsnotify.Watcher) *Context { 285 config = initConfigDefaults(config) 286 287 return NewContext(&Args{ 288 Log: config.Log, 289 LogColor: config.LogColor, 290 Port: config.Port, 291 Pool: NewPool(config.Log, config.Concurrency), 292 SourceDir: config.SourceDir, 293 TargetDir: config.TargetDir, 294 Watcher: watcher, 295 Websocket: config.Websocket, 296 }) 297 } 298 299 // Extract the names of keys out of a map and return them as a slice. 300 func mapKeys(m map[string]struct{}) []string { 301 keys := make([]string, 0, len(m)) 302 for key := range m { 303 keys = append(keys, key) 304 } 305 return keys 306 } 307 308 // Replaces the current process with a fresh one by invoking the same 309 // executable with the operating system's exec syscall. This is prompted by the 310 // USR2 signal and is intended to allow the process to refresh itself in the 311 // case where it's source files changed and it was recompiled. 312 // 313 // The fsnotify watcher and HTTP server are shut down as gracefully as possible 314 // before the replacement occurs. 315 func shutdownAndExec(c *Context, finish chan struct{}, 316 watcher *fsnotify.Watcher, server *http.Server, 317 ) { 318 // Tell the build loop to finish up 319 finish <- struct{}{} 320 321 // DANGER: Defers don't seem to get called on the re-exec, so even though 322 // we have a defer which closes our watcher, it won't close, leading to 323 // file descriptor leaking. Close it manually here instead. 324 watcher.Close() 325 326 // A context that will act as a timeout for connections 327 // that are still running as we try and shut down the HTTP 328 // server. 329 timeoutCtx, cancel := context.WithTimeout( 330 context.Background(), 331 5*time.Second, 332 ) 333 334 c.Log.Infof("Shutting down HTTP server") 335 if err := server.Shutdown(timeoutCtx); err != nil { 336 exitWithError(err) 337 } 338 339 cancel() 340 341 // Returns an absolute path. 342 execPath, err := os.Executable() 343 if err != nil { 344 exitWithError(err) 345 } 346 347 c.Log.Infof("Execing process '%s' with args %+v\n", execPath, os.Args) 348 if err := unix.Exec(execPath, os.Args, os.Environ()); err != nil { 349 exitWithError(err) 350 } 351 }