~artemis/cap

799e38b39a7da9664cd6127723e1a8f6fd06bc20 — Artemis 1 year, 3 months ago 4ff118e v0.4.0
feat: embedded gemini server
10 files changed, 119 insertions(+), 36 deletions(-)

D devnotes.gmi
M go.mod
M go.sum
M preview/managed/context.go
M preview/manager.go
A preview/servers/capability.go
A preview/servers/gemini.go
R preview/{content_servers.go => servers/http.go}
M types.go
M watcher.go
D devnotes.gmi => devnotes.gmi +0 -6
@@ 1,6 0,0 @@
# Development notes

The core goal here is to add support for a natively integrated embedded gemini server in the watch mode.
However, while trivial to do in its core, it would also be good to take this occasion to work on making the watch features more easy to toggle and choose.

=> https://git.sr.ht/~adnano/go-gemini the gemini server library i will probably use

M go.mod => go.mod +2 -0
@@ 21,6 21,7 @@ require (
)

require (
	git.sr.ht/~adnano/go-gemini v0.2.3 // indirect
	github.com/BurntSushi/toml v1.2.1 // indirect
	github.com/alexflint/go-scalar v1.2.0 // indirect
	github.com/andybalholm/cascadia v1.3.1 // indirect


@@ 35,6 36,7 @@ require (
	golang.org/x/net v0.6.0 // indirect
	golang.org/x/sys v0.5.0 // indirect
	golang.org/x/term v0.5.0 // indirect
	golang.org/x/text v0.7.0 // indirect
	gopkg.in/go-playground/assert.v1 v1.2.1 // indirect
	gopkg.in/yaml.v2 v2.4.0 // indirect
)

M go.sum => go.sum +6 -0
@@ 1,3 1,5 @@
git.sr.ht/~adnano/go-gemini v0.2.3 h1:oJ+Y0/mheZ4Vg0ABjtf5dlmvq1yoONStiaQvmWWkofc=
git.sr.ht/~adnano/go-gemini v0.2.3/go.mod h1:hQ75Y0i5jSFL+FQ7AzWVAYr5LQsaFC7v3ZviNyj46dY=
git.sr.ht/~justinsantoro/gemtext v0.0.0-20220805224016-0f9b62e585a8 h1:1QVRbgTKSTeo+XXFh4Cm8X5ibhN6OiAWWcu/ZAwXzEw=
git.sr.ht/~justinsantoro/gemtext v0.0.0-20220805224016-0f9b62e585a8/go.mod h1:DIXGwCsBn8cQxd21fPDLW8ww5tNq8/P4ZdUXXzqNPrg=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=


@@ 76,6 78,7 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.6.0 h1:L4ZwwTvKW9gr0ZMS1yrHD9GZhIuVjOBBnaKH+SPQK0Q=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=


@@ 89,7 92,10 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

M preview/managed/context.go => preview/managed/context.go +7 -3
@@ 1,14 1,18 @@
package managed

import "sync"
import (
	"context"
	"sync"
)

type ManagedContext struct {
	wg     *sync.WaitGroup
	errors chan error
	RunCtx context.Context
}

func New(wg *sync.WaitGroup, errors chan error) ManagedContext {
	return ManagedContext{wg, errors}
func New(wg *sync.WaitGroup, errors chan error, ctx context.Context) ManagedContext {
	return ManagedContext{wg, errors, ctx}
}

func (m *ManagedContext) Done(err error) {

M preview/manager.go => preview/manager.go +11 -6
@@ 5,17 5,20 @@ package preview
import (
	"context"
	"errors"
	"os"
	"path/filepath"
	"sync"
	"time"

	"git.sr.ht/~artemis/cap/preview/managed"
	"git.sr.ht/~artemis/cap/preview/servers"
	"git.sr.ht/~artemis/cap/preview/watch"
	"github.com/withmandala/go-log"
)

type Manager interface {
	AddWatcher(watcher watch.WatchCapability)
	AddContentServer(server ContentServerCapability)
	AddContentServer(server servers.ContentServerCapability)
	// Starts all the requested capabilities then hangs until all capability routines are stopped
	Watch(ctx context.Context, capsuleRoot string, rebuild watch.RebuildCallback) error
}


@@ 26,7 29,7 @@ type Stoppable interface {

type PreviewManager struct {
	watchers []watch.WatchCapability
	servers  []ContentServerCapability
	servers  []servers.ContentServerCapability
	logger   *log.Logger
}



@@ 40,21 43,22 @@ func (p *PreviewManager) AddWatcher(watcher watch.WatchCapability) {
	}
}

func (p *PreviewManager) AddContentServer(server ContentServerCapability) {
func (p *PreviewManager) AddContentServer(server servers.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)
	ctx := managed.New(&wg, routineErrors, runCtx)

	hasErrored := false
	var started []Stoppable

	for _, server := range p.servers {
		err := server.Start(&ctx, capsuleRoot)
		builtContentRoot := filepath.Join(capsuleRoot, ".public")
		capsuleFS := os.DirFS(builtContentRoot)
		err := server.Start(&ctx, capsuleFS)

		if err != nil {
			p.logger.Errorf("Server startup error: %s", err)


@@ 76,6 80,7 @@ func (p *PreviewManager) Watch(runCtx context.Context, capsuleRoot string, rebui
		}
	}

	shutdownCtx, shutdown := context.WithTimeout(runCtx, time.Second*5)
	if hasErrored {
		for _, running := range started {
			running.Stop(shutdownCtx)

A preview/servers/capability.go => preview/servers/capability.go +16 -0
@@ 0,0 1,16 @@
package servers

import (
	"context"
	"io/fs"

	"git.sr.ht/~artemis/cap/preview/managed"
)

// Generic embedded server interface for the preview environment
type ContentServerCapability interface {
	// Starts the embedded server on another routine
	Start(ctx *managed.ManagedContext, capsuleRoot fs.FS) error
	// Stops the embedded server
	Stop(context.Context)
}

A preview/servers/gemini.go => preview/servers/gemini.go +60 -0
@@ 0,0 1,60 @@
package servers

import (
	"context"
	"io/fs"
	"time"

	"git.sr.ht/~adnano/go-gemini"
	"git.sr.ht/~adnano/go-gemini/certificate"
	"git.sr.ht/~artemis/cap/preview/managed"
	"github.com/withmandala/go-log"
)

// CreateEmbeddedGeminiServer provides a Gemini server implementation
func CreateEmbeddedGeminiServer(address string, logger *log.Logger) ContentServerCapability {
	return &EmbeddedGeminiServer{
		address: address,
		logger:  logger,
	}
}

// EmbeddedGeminiServer is the embedded Gemini server implementation
type EmbeddedGeminiServer struct {
	address string
	logger  *log.Logger
	server  *gemini.Server
}

// Start implements ContentServerCapability
func (e *EmbeddedGeminiServer) Start(ctx *managed.ManagedContext, contentRoot fs.FS) error {
	certStore := &certificate.Store{}
	certStore.Register("*")

	e.server = &gemini.Server{
		Addr:           e.address,
		GetCertificate: certStore.Get,
		ReadTimeout:    2 * time.Second,
		WriteTimeout:   2 * time.Second,
		Handler:        gemini.FileServer(contentRoot),
	}

	go func(ctx *managed.ManagedContext, e *EmbeddedGeminiServer) {
		err := e.server.ListenAndServe(ctx.RunCtx)
		if err != nil {
			e.logger.Errorf("Gemini server error: %s", err.Error())
			ctx.Done(err)
		}
		ctx.Done(nil)
	}(ctx, e)
	e.logger.Infof("Embedded Gemini server running on gemini://%s", e.address)

	return nil
}

// Stop implements ContentServerCapability
func (e *EmbeddedGeminiServer) Stop(ctx context.Context) {
	if e.server != nil {
		e.server.Shutdown(ctx)
	}
}

R preview/content_servers.go => preview/servers/http.go +10 -17
@@ 1,55 1,48 @@
// the http.go file defines the embedded HTTP server logic
package preview
package servers

import (
	"context"
	"io/fs"
	"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 {
// CreateEmbeddedHttpServer provides a HTTP server implementation
func CreateEmbeddedHttpServer(address string, logger *log.Logger) ContentServerCapability {
	return &EmbeddedHttpServer{
		address: address,
		logger:  logger,
	}
}

// HTTP server implementation for the embedded server
// EmbeddedHttpServer is the embedded HTTP server implementation
type EmbeddedHttpServer struct {
	address string
	server  *http.Server
	logger  *log.Logger
}

func (e *EmbeddedHttpServer) Start(ctx *managed.ManagedContext, capsuleRoot string) error {
func (e *EmbeddedHttpServer) Start(ctx *managed.ManagedContext, contentRoot fs.FS) 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"))),
		Handler:      http.FileServer(http.FS(contentRoot)),
	}

	go func(ctx *managed.ManagedContext, server *http.Server) {
		err := server.ListenAndServe()
		if err != http.ErrServerClosed {
			e.logger.Errorf("HTTP server error: %s", err.Error())
			ctx.Done(err)
		}
		ctx.Done(nil)
	}(ctx, e.server)
	e.logger.Infof("Embedded server running on http://%s", e.address)
	e.logger.Infof("Embedded HTTP server running on http://%s", e.address)

	return nil
}


M types.go => types.go +4 -3
@@ 97,9 97,10 @@ 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"`
	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"`
	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)"`
	HttpListenAddress   string `arg:"-l,--listen" default:"127.0.0.1:8080" help:"specifies the address the embedded http server will listen on"`
	GeminiListenAddress string `arg:"-g,--gemini-listen" default:"127.0.0.1:1965" help:"specifies the address the embedded gemini server will listen on"`
}

// Args contains all flags, arguments, and subcommands for cap(1)

M watcher.go => watcher.go +3 -1
@@ 5,6 5,7 @@ import (

	"git.sr.ht/~artemis/cap/domain"
	"git.sr.ht/~artemis/cap/preview"
	"git.sr.ht/~artemis/cap/preview/servers"
	"git.sr.ht/~artemis/cap/preview/watch"
)



@@ 19,7 20,8 @@ func Watch(root string, args *Args) error {
	manager := preview.CreateManager(domain.L)

	if !args.WatchCmd.DisableHttpServer {
		manager.AddContentServer(preview.CreateHttpEmbeddedServer(args.WatchCmd.ListenAddress, domain.L))
		manager.AddContentServer(servers.CreateEmbeddedHttpServer(args.WatchCmd.HttpListenAddress, domain.L))
		manager.AddContentServer(servers.CreateEmbeddedGeminiServer(args.WatchCmd.GeminiListenAddress, domain.L))
	}

	manager.AddWatcher(watch.CreateSignalWatcher(domain.L))