~artemis/cap

81a0964838046a84fe162dcaa31e36a52d6b5e45 — Artémis 1 year, 9 months ago 8848a33 v0.2.0
feat/watcher-split: split + cleanup + cool stuff in the watcher

* watcher split into "content server" and "rebuild watcher"
* content servers: http
* rebuild watchers: sys notify (auto), SIGUSR1 (semi-auto)
* removed the poll watcher (replaced by sys notify)
* made auto-rebuild watchers optional on watch (new arg: `-r`)
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
	})
}