]> git.earlybird.gay Git - today/commitdiff
Initial commit
authorearly <me@earlybird.gay>
Tue, 13 Aug 2024 00:16:50 +0000 (18:16 -0600)
committerearly <me@earlybird.gay>
Tue, 13 Aug 2024 00:16:50 +0000 (18:16 -0600)
README [new file with mode: 0644]
app/app.go [new file with mode: 0644]
app/env.go [new file with mode: 0644]
app/process.go [new file with mode: 0644]
go.mod [new file with mode: 0644]

diff --git a/README b/README
new file mode 100644 (file)
index 0000000..f55d7cb
--- /dev/null
+++ b/README
@@ -0,0 +1,29 @@
+===== Today Web Apps ===========================================================
+
+This respository contains all the code and applications related to the Today web
+app framework.
+
+Author: Early N.
+
+===== Usage and License ========================================================
+
+Copyright 2024 Early
+
+Don't.
+
+1. This work is deliberately unlicensed, and I reserve all rights until I think
+   it's ready to be released.
+2. It's so very not ready for release. Untested, undocumented, messy. Seriously,
+   even if you're going to do a piracy, don't do it with this.
+
+===== Project Goals ============================================================
+
+Build a framework around HTTP handlers that makes hard problems a bit easier.
+
+1. (Done!) Environment management. Give apps a unified set of environment
+   variables for commonly-changed things, like hosts, logging, etc.
+2. (Done!) Process management. Ungraceful exits are lame, exits that could be
+   graceful but aren't are also lame.
+2. (Done!) Subprocess management. It should be pretty easy to implement a
+   reponsible subprocess that knows when it should stop and clean up after
+   itself.
diff --git a/app/app.go b/app/app.go
new file mode 100644 (file)
index 0000000..8ab8a78
--- /dev/null
@@ -0,0 +1,110 @@
+// Copyright (C) 2024 early (LGPL)
+package app
+
+import (
+       "context"
+       "errors"
+       "log/slog"
+       "net"
+       "net/http"
+       "os"
+       "strings"
+       "sync"
+       "sync/atomic"
+)
+
+type App struct {
+       http.Server
+       http.ServeMux
+
+       Logger *slog.Logger
+
+       running        atomic.Bool
+       wg             sync.WaitGroup
+       shutdown, stop chan struct{}
+}
+
+func New() *App {
+       app := new(App)
+       app.shutdown = make(chan struct{})
+       app.stop = make(chan struct{})
+       return app
+}
+
+func (app *App) setDefaults() {
+       if app.Server.Handler == nil {
+               app.Server.Handler = app
+       }
+       if app.Addr == "" {
+               app.Addr = "0.0.0.0:3000"
+       }
+       if app.Logger == nil {
+               app.Logger = slog.New(slog.NewTextHandler(os.Stdout, nil))
+       }
+}
+
+// Subprocess will run process and ask the app to wait on it to close.
+// Process must accept a channel that indicates when it should stop, and it
+// should respect that command when possible.
+func (app *App) Subprocess(process func(done <-chan struct{}) error) {
+       done := make(chan error)
+       go func() {
+               done <- process(app.shutdown)
+       }()
+       app.wg.Add(1)
+       go func() {
+               select {
+               case err := <-done:
+                       if err != nil {
+                               app.Logger.Warn("subprocess exited with error", "detail", err)
+                       }
+               case <-app.stop:
+               }
+               app.wg.Done()
+       }()
+}
+
+func (app *App) ListenAndServe() error {
+       app.setDefaults()
+       app.Logger.Info("application starting", "host", app.Addr)
+
+       var listener net.Listener
+       var err error
+       if i := strings.Index(app.Addr, "://"); i != -1 {
+               scheme := app.Addr[:i]
+               host := app.Addr[i+3:]
+               switch scheme {
+               case "unix":
+                       listener, err = net.Listen("unix", host)
+               default:
+                       listener, err = net.Listen("tcp", host)
+               }
+       } else {
+               listener, err = net.Listen("tcp", app.Addr)
+       }
+       if err != nil {
+               return err
+       }
+
+       app.running.Store(true)
+       err = app.Server.Serve(listener)
+
+       app.Logger.Info("application stopped", "reason", err)
+       return nil
+}
+
+func (app *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+       app.Logger.Debug("serving request", "host", r.Host, "path", r.URL.Path)
+       app.ServeMux.ServeHTTP(w, r)
+}
+
+func (app *App) Shutdown(ctx context.Context) error {
+       if app.running.CompareAndSwap(true, false) {
+               app.Logger.Info("application shutting down")
+               app.running.Store(false)
+               close(app.shutdown)
+               app.wg.Wait()
+               return app.Server.Shutdown(ctx)
+       }
+       return errors.New("cannot Shutdown an app that isn't running")
+}
diff --git a/app/env.go b/app/env.go
new file mode 100644 (file)
index 0000000..7524b5d
--- /dev/null
@@ -0,0 +1,81 @@
+package app
+
+import (
+       "fmt"
+       "log/slog"
+       "os"
+       "strings"
+)
+
+const (
+       ENV_APP_NAME    = "APP_NAME"
+       ENV_LISTEN_HTTP = "LISTEN_HTTP"
+       ENV_LOG_LEVEL   = "LOG_LEVEL"
+)
+
+var (
+       logLevels = map[string]slog.Level{
+               "debug":   slog.LevelDebug,
+               "info":    slog.LevelInfo,
+               "warn":    slog.LevelWarn,
+               "warning": slog.LevelWarn,
+               "error":   slog.LevelError,
+               "err":     slog.LevelError,
+       }
+)
+
+type Env struct {
+       AppName    string
+       ListenHTTP string
+       LogLevel   slog.Level
+}
+
+type AcceptsEnv interface {
+       Apply(env Env)
+}
+
+// GetEnv gets a set of values from environment variables that are common when
+// running web applications.
+// You don't have to use GetEnv for this
+func GetEnv() Env {
+       var env Env
+       env.AppName = os.Getenv(ENV_APP_NAME)
+       env.ListenHTTP = os.Getenv(ENV_LISTEN_HTTP)
+       logLevelStr := strings.ToLower(os.Getenv(ENV_LOG_LEVEL))
+       if logLevel, ok := logLevels[logLevelStr]; ok {
+               env.LogLevel = logLevel
+       }
+       return env
+}
+
+func (env Env) Apply(app *App) {
+       app.Addr = env.ListenHTTP
+       if app.Logger == nil {
+               app.Logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
+                       Level: env.LogLevel,
+               }))
+               if env.AppName != "" {
+                       app.Logger = app.Logger.With("app", env.AppName)
+               }
+       }
+}
+
+// AppendEnv appends the env as key=value pairs to a list of vars.
+// This is intended for use with os.Environ() to pass environments to
+// subprocesses:
+//
+//     cmd := exec.Command(...)
+//     osEnv := os.Environ()
+//     subprocessEnv := app.Env{...}
+//     osEnv = app.AppendEnv(osEnv, subprocessEnv)
+//     cmd.Exec()
+func AppendEnv(vars []string, env Env) []string {
+       if env.AppName != "" {
+               vars = append(vars, fmt.Sprintf("%s=%s", ENV_APP_NAME, env.AppName))
+       }
+       if env.ListenHTTP != "" {
+               vars = append(vars, fmt.Sprintf("%s=%s", ENV_LISTEN_HTTP, env.ListenHTTP))
+       }
+       vars = append(vars, fmt.Sprintf("%s=%s", ENV_LOG_LEVEL, env.LogLevel.String()))
+       return vars
+}
diff --git a/app/process.go b/app/process.go
new file mode 100644 (file)
index 0000000..d6ed116
--- /dev/null
@@ -0,0 +1,24 @@
+package app
+
+import (
+       "context"
+       "os"
+       "os/signal"
+       "syscall"
+)
+
+// ShutdownOnSignal will start the process of an app shutting down if an OS
+// signal is received.
+func (app *App) ShutdownOnSignal(sigs ...os.Signal) {
+       sigc := make(chan os.Signal, 1)
+       signal.Notify(sigc, syscall.SIGTERM, syscall.SIGINT)
+       app.Subprocess(func(done <-chan struct{}) error {
+               select {
+               case sig := <-sigc:
+                       app.Logger.Info("received signal", "type", sig)
+                       go app.Shutdown(context.Background())
+               case <-done:
+               }
+               return nil
+       })
+}
diff --git a/go.mod b/go.mod
new file mode 100644 (file)
index 0000000..a2595c8
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module git.earlybird.gay/today-app
+
+go 1.22.4