M cap.go => cap.go +1 -1
@@ 69,7 69,7 @@ func main() {
domain.L.Debugf("Capsule root found at `%s`", root)
if err := Watch(root, &args); err != nil {
- domain.L.Errorf("Couldn't watch the capsule\n%v", err)
+ domain.L.Errorf("Couldn't watch the capsule: %v", err)
os.Exit(1)
}
} else {
M config.go => config.go +2 -0
@@ 52,6 52,8 @@ func LoadConfig(path string) (*Config, error) {
}
}
+ outputCfg.FoldersToIgnore = append(outputCfg.FoldersToIgnore, ".git", ".public")
+
if err = validator.New().Struct(outputCfg); err != nil {
return nil, fmt.Errorf("in the capsule's config file,\n%s", err.Error())
}
M go.mod => go.mod +1 -1
@@ 10,12 10,12 @@ require (
github.com/alexflint/go-arg v1.4.3
github.com/blevesearch/segment v0.9.1
github.com/eduncan911/podcast v1.4.2
+ github.com/fsnotify/fsnotify v1.6.0
github.com/go-playground/validator v9.31.0+incompatible
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/imdario/mergo v0.3.13
github.com/microcosm-cc/bluemonday v1.0.22
github.com/pelletier/go-toml/v2 v2.0.6
- github.com/radovskyb/watcher v1.0.7
github.com/russross/blackfriday/v2 v2.1.0
github.com/withmandala/go-log v0.1.0
)
M go.sum => go.sum +3 -2
@@ 25,6 25,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/eduncan911/podcast v1.4.2 h1:S+fsUlbR2ULFou2Mc52G/MZI8JVJHedbxLQnoA+MY/w=
github.com/eduncan911/podcast v1.4.2/go.mod h1:mSxiK1z5KeNO0YFaQ3ElJlUZbbDV9dA7R9c1coeeXkc=
+github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
+github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
@@ 51,8 53,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/radovskyb/watcher v1.0.7 h1:AYePLih6dpmS32vlHfhCeli8127LzkIgwJGcwwe8tUE=
-github.com/radovskyb/watcher v1.0.7/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs=
@@ 82,6 82,7 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
A preview/content_servers.go => preview/content_servers.go +60 -0
@@ 0,0 1,60 @@
+// the http.go file defines the embedded HTTP server logic
+package preview
+
+import (
+ "context"
+ "net/http"
+ "path/filepath"
+ "time"
+
+ "git.sr.ht/~artemis/cap/preview/managed"
+ "github.com/withmandala/go-log"
+)
+
+// Generic embedded server interface for the preview environment
+type ContentServerCapability interface {
+ // Starts the embedded server on another routine
+ Start(ctx *managed.ManagedContext, capsuleRoot string) error
+ // Stops the embedded server
+ Stop(context.Context)
+}
+
+// CreateHttpEmbeddedServer provides a HTTP server implementation for the embedded server
+func CreateHttpEmbeddedServer(address string, logger *log.Logger) ContentServerCapability {
+ return &EmbeddedHttpServer{
+ address: address,
+ logger: logger,
+ }
+}
+
+// HTTP server implementation for the embedded server
+type EmbeddedHttpServer struct {
+ address string
+ server *http.Server
+ logger *log.Logger
+}
+
+func (e *EmbeddedHttpServer) Start(ctx *managed.ManagedContext, capsuleRoot string) error {
+ e.server = &http.Server{
+ Addr: e.address,
+ ReadTimeout: 2 * time.Second,
+ WriteTimeout: 2 * time.Second,
+ Handler: http.FileServer(http.Dir(filepath.Join(capsuleRoot, ".public"))),
+ }
+
+ go func(ctx *managed.ManagedContext, server *http.Server) {
+ err := server.ListenAndServe()
+ if err != http.ErrServerClosed {
+ ctx.Done(err)
+ }
+ ctx.Done(nil)
+ }(ctx, e.server)
+ e.logger.Infof("Embedded server running on http://%s", e.address)
+ return nil
+}
+
+func (e *EmbeddedHttpServer) Stop(ctx context.Context) {
+ if e.server != nil {
+ e.server.Shutdown(ctx)
+ }
+}
A preview/doc.go => preview/doc.go +34 -0
@@ 0,0 1,34 @@
+// Package preview provides a long-running process including a few tools to assist the writer.
+// It provides content server capabilities as well as auto and semi-auto rebuild capabilities.
+//
+// ## Content servers
+//
+// The content servers are capabilities providing a content delivery server made to deliver content from the `.public` folder as-is.
+// The only currently provided content server is a HTTP server.
+//
+// ### Future content server updates that would be good
+//
+// * a Gemini implementation
+// * for the HTTP server, add support for delivering .html files with and without the .html extension suffix
+// * for the HTTP server, the ability to always append a piece of HTML code when the served file is a HTML one
+// * a websocket server broadcasting a rebuild notification on rebuild, e.g. to allow auto-reload from a webpage
+//
+// ## Rebuild capabilities
+//
+// The rebuild capabilities provide a way to automatically trigger a capsule rebuild without having to re-run `cap build` manually.
+// Autonomous implementations will usually watch the file-system for changes and trigger a rebuild if any was found.
+// Semi-autonomous implementations will provide some form of hook offering the ability to trigger a rebuild.
+//
+// ### Implemented rebuild capabilities
+//
+// * AUTO - poll-based rebuild
+//
+// ### Future rebuild capability updates that would be good
+//
+// * AUTO - libnotify-based rebuild for linux
+// * SEMI-AUTO - signal-triggered rebuild (SIGUSR1)
+//
+// ---
+//
+// Pending note: no clear definition on how capability-specific settings and capability selection will be provided.
+package preview
A preview/managed/context.go => preview/managed/context.go +19 -0
@@ 0,0 1,19 @@
+package managed
+
+import "sync"
+
+type ManagedContext struct {
+ wg *sync.WaitGroup
+ errors chan error
+}
+
+func New(wg *sync.WaitGroup, errors chan error) ManagedContext {
+ return ManagedContext{wg, errors}
+}
+
+func (m *ManagedContext) Done(err error) {
+ if err != nil {
+ m.errors <- err
+ }
+ m.wg.Done()
+}
A preview/manager.go => preview/manager.go +94 -0
@@ 0,0 1,94 @@
+// manager.go is tasked with providing a common layer for creating servers and rebuilders
+// as well as handling wait/shutdown
+package preview
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "time"
+
+ "git.sr.ht/~artemis/cap/preview/managed"
+ "git.sr.ht/~artemis/cap/preview/watch"
+ "github.com/withmandala/go-log"
+)
+
+type Manager interface {
+ AddWatcher(watcher watch.WatchCapability)
+ AddContentServer(server ContentServerCapability)
+ // Starts all the requested capabilities then hangs until all capability routines are stopped
+ Watch(ctx context.Context, capsuleRoot string, rebuild watch.RebuildCallback) error
+}
+
+type Stoppable interface {
+ Stop(context.Context)
+}
+
+type PreviewManager struct {
+ watchers []watch.WatchCapability
+ servers []ContentServerCapability
+ logger *log.Logger
+}
+
+func CreateManager(logger *log.Logger) Manager {
+ return &PreviewManager{logger: logger}
+}
+
+func (p *PreviewManager) AddWatcher(watcher watch.WatchCapability) {
+ p.watchers = append(p.watchers, watcher)
+}
+
+func (p *PreviewManager) AddContentServer(server ContentServerCapability) {
+ p.servers = append(p.servers, server)
+}
+
+func (p *PreviewManager) Watch(runCtx context.Context, capsuleRoot string, rebuild watch.RebuildCallback) error {
+ wg := sync.WaitGroup{}
+ routineErrors := make(chan error, len(p.servers)+len(p.watchers))
+ shutdownCtx, shutdown := context.WithTimeout(runCtx, time.Second*5)
+ ctx := managed.New(&wg, routineErrors)
+
+ hasErrored := false
+ var started []Stoppable
+
+ for _, server := range p.servers {
+ err := server.Start(&ctx, capsuleRoot)
+
+ if err != nil {
+ p.logger.Errorf("Server startup error: %s", err)
+ hasErrored = true
+ }
+ started = append(started, server)
+ wg.Add(1)
+ }
+ if !hasErrored {
+ for _, watcher := range p.watchers {
+ err := watcher.Start(&ctx, capsuleRoot, rebuild)
+
+ if err != nil {
+ p.logger.Errorf("Watcher startup error: %s", err)
+ hasErrored = true
+ }
+ started = append(started, watcher)
+ wg.Add(1)
+ }
+ }
+
+ if hasErrored {
+ for _, running := range started {
+ running.Stop(shutdownCtx)
+ }
+ }
+ wg.Wait()
+ shutdown()
+ close(routineErrors)
+ for err := range routineErrors {
+ p.logger.Errorf("Routine error: %s", err)
+ hasErrored = true
+ }
+
+ if hasErrored {
+ return errors.New("preview mode encountered errors")
+ }
+ return nil
+}
A preview/watch/capability.go => preview/watch/capability.go +19 -0
@@ 0,0 1,19 @@
+package watch
+
+import (
+ "context"
+
+ "git.sr.ht/~artemis/cap/preview/managed"
+)
+
+// Generic watch capability interface
+type WatchCapability interface {
+ // Starts the watcher routine
+ // `capsuleRoot` is the absolute path to the capsule's root
+ // `rebuild` is the callback called on every needed rebuild
+ Start(ctx *managed.ManagedContext, capsuleRoot string, rebuild RebuildCallback) error
+ // Stops the watcher
+ Stop(context.Context)
+}
+
+type RebuildCallback func() error
A preview/watch/crossplatform_notify.go => preview/watch/crossplatform_notify.go +100 -0
@@ 0,0 1,100 @@
+package watch
+
+import (
+ "context"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "git.sr.ht/~artemis/cap/preview/managed"
+ "git.sr.ht/~artemis/cap/util"
+ "github.com/fsnotify/fsnotify"
+ "github.com/withmandala/go-log"
+)
+
+type CrossPlatform struct {
+ foldersToIgnore []string
+ watcher *fsnotify.Watcher
+ logger *log.Logger
+}
+
+func CreateCrossPlatformWatcher(foldersToIgnore []string, logger *log.Logger) WatchCapability {
+ return &CrossPlatform{
+ foldersToIgnore: foldersToIgnore,
+ logger: logger,
+ }
+}
+
+func (p *CrossPlatform) Start(ctx *managed.ManagedContext, capsuleRoot string, rebuild RebuildCallback) error {
+ // watch startup
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ return err
+ }
+ p.watcher = watcher
+
+ go func(ctx *managed.ManagedContext) {
+ lastBuild := time.Now()
+ loop:
+ for {
+ select {
+ case event, ok := <-watcher.Events:
+ if !ok {
+ break loop
+ }
+ p.logger.Debugf("Event: %v", event)
+ if time.Since(lastBuild).Milliseconds() < 100 {
+ p.logger.Debug("Debouncing event...")
+ continue
+ }
+ p.logger.Info("Found changes, rebuilding...")
+
+ if info, err := os.Stat(event.Name); err == nil &&
+ event.Has(fsnotify.Create) &&
+ info != nil &&
+ info.IsDir() {
+ err = watcher.Add(event.Name)
+ if err != nil {
+ ctx.Done(err)
+ return
+ }
+ }
+
+ if err := rebuild(); err != nil {
+ p.logger.Errorf("Failed to rebuild...\n%v", err)
+ }
+ lastBuild = time.Now()
+ case err, ok := <-watcher.Errors:
+ if !ok {
+ break loop
+ }
+ ctx.Done(err)
+ return
+ }
+ }
+
+ ctx.Done(nil)
+ }(ctx)
+
+ return filepath.WalkDir(capsuleRoot, func(path string, d fs.DirEntry, err error) error {
+ // Ignoring unneeded folders
+ if err != nil || !d.IsDir() {
+ return err
+ }
+ chunks := strings.Split(path, string(filepath.Separator))
+
+ for _, folder := range p.foldersToIgnore {
+ if util.Includes(folder, chunks...) {
+ return filepath.SkipDir
+ }
+ }
+
+ return watcher.Add(path)
+ })
+}
+
+func (p *CrossPlatform) Stop(context.Context) {
+ p.watcher.Close()
+}
A preview/watch/signal.go => preview/watch/signal.go +71 -0
@@ 0,0 1,71 @@
+package watch
+
+import (
+ "context"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "git.sr.ht/~artemis/cap/preview/managed"
+ "github.com/withmandala/go-log"
+)
+
+type Signal struct {
+ done chan struct{}
+ logger *log.Logger
+}
+
+func CreateSignalWatcher(logger *log.Logger) WatchCapability {
+ return &Signal{
+ done: make(chan struct{}, 1),
+ logger: logger,
+ }
+}
+
+// Start implements WatchCapability
+func (s *Signal) Start(ctx *managed.ManagedContext, _ string, rebuild RebuildCallback) error {
+ sigs := make(chan os.Signal, 1)
+ signal.Notify(sigs, syscall.SIGUSR1)
+
+ go func(sigs chan os.Signal, done chan struct{}, ctx *managed.ManagedContext) {
+ lastBuild := time.Now()
+ defer func() {
+ close(sigs)
+ close(done)
+ }()
+
+ loop:
+ for {
+ select {
+ case _, ok := <-sigs:
+ if !ok {
+ break loop
+ }
+ if time.Since(lastBuild).Milliseconds() < 100 {
+ s.logger.Debug("Debouncing signal...")
+ continue loop
+ }
+
+ if err := rebuild(); err != nil {
+ ctx.Done(err)
+ return
+ }
+
+ lastBuild = time.Now()
+ case _, ok := <-done:
+ if !ok {
+ break loop
+ }
+ }
+ }
+ ctx.Done(nil)
+ }(sigs, s.done, ctx)
+
+ return nil
+}
+
+// Stop implements WatchCapability
+func (s *Signal) Stop(context.Context) {
+ s.done <- struct{}{}
+}
M types.go => types.go +3 -2
@@ 88,8 88,9 @@ type Config struct {
// WatchCMD contains the subcommand's flags and arguments
type WatchCMD struct {
- DisableHttpServer bool `arg:"-d,--disable-http" help:"don't start the embedded http server"`
- ListenAddress string `arg:"-l,--listen" default:"127.0.0.1:8080" help:"specifies the address the embedded http server will listen on"`
+ DisableHttpServer bool `arg:"-d,--disable-http" help:"don't start the embedded http server"`
+ DisableAutoRebuild bool `arg:"-r,--disable-auto-rebuild" help:"don't start to watch for changes (a rebuild can still be triggered by sending USR1 to the process)"`
+ ListenAddress string `arg:"-l,--listen" default:"127.0.0.1:8080" help:"specifies the address the embedded http server will listen on"`
}
// Args contains all flags, arguments, and subcommands for cap(1)
M watcher.go => watcher.go +12 -74
@@ 2,29 2,12 @@ package main
import (
"context"
- "net/http"
- "os"
- "path/filepath"
- "strings"
- "time"
"git.sr.ht/~artemis/cap/domain"
- "git.sr.ht/~artemis/cap/util"
- "github.com/radovskyb/watcher"
+ "git.sr.ht/~artemis/cap/preview"
+ "git.sr.ht/~artemis/cap/preview/watch"
)
-// BuildHttpServer builds the http server struct.
-func BuildHttpServer(root string, addr string) *http.Server {
- s := &http.Server{
- Addr: addr,
- ReadTimeout: 2 * time.Second,
- WriteTimeout: 2 * time.Second,
- Handler: http.FileServer(http.Dir(filepath.Join(root, ".public"))),
- }
-
- return s
-}
-
// Watch builds a first copy of the capsule, then watches for file changes to rebuild the capsule; unless disabled, it will start a HTTP server.
func Watch(root string, args *Args) error {
// preliminary build
@@ 33,64 16,19 @@ func Watch(root string, args *Args) error {
domain.L.Errorf("Failed to build...\n%v\nCarrying on starting the server.", err)
}
- // embedded server startup
- server := BuildHttpServer(root, args.WatchCmd.ListenAddress)
+ manager := preview.CreateManager(domain.L)
+
if !args.WatchCmd.DisableHttpServer {
- domain.L.Infof("Embedded server running on http://%s", args.WatchCmd.ListenAddress)
- go server.ListenAndServe()
+ manager.AddContentServer(preview.CreateHttpEmbeddedServer(args.WatchCmd.ListenAddress, domain.L))
}
- // watch startup
- w := watcher.New()
-
- // Ignoring unneeded folders
- // By default, ignores .git and .public (cannot be de-ignored)
- w.AddFilterHook(func(info os.FileInfo, fullPath string) error {
- chunks := strings.Split(fullPath, string(filepath.Separator))
-
- for _, folder := range cfg.FoldersToIgnore {
- if util.Includes(folder, chunks...) {
- return watcher.ErrSkip
- }
- }
-
- if util.Includes(".git", chunks...) ||
- util.Includes(".public", chunks...) {
- return watcher.ErrSkip
- }
-
- return nil
- })
-
- go func() {
- for {
- select {
- case event := <-w.Event:
- if event.IsDir() && (event.Op == watcher.Write ||
- event.Op == watcher.Create ||
- event.Op == watcher.Remove) {
- continue // Useless dir write event
- }
- domain.L.Info("Found changes, rebuilding...")
- if args.Verbose {
- domain.L.Debugf("Event: %v", event)
- }
-
- if _, err := Build(root, args); err != nil {
- domain.L.Errorf("Failed to rebuild...\n%v", err)
- }
- case err := <-w.Error:
- domain.L.Errorf("watch error: %s", err.Error())
- case <-w.Closed:
- server.Shutdown(context.Background())
- return
- }
- }
- }()
-
- if err := w.AddRecursive(root); err != nil {
- return err
+ manager.AddWatcher(watch.CreateSignalWatcher(domain.L))
+ if !args.WatchCmd.DisableAutoRebuild {
+ manager.AddWatcher(watch.CreateCrossPlatformWatcher(cfg.FoldersToIgnore, domain.L))
}
- return w.Start(time.Millisecond * 100)
+ return manager.Watch(context.Background(), root, func() error {
+ _, err := Build(root, args)
+ return err
+ })
}