--- /dev/null
+===== 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.
--- /dev/null
+// 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")
+}
--- /dev/null
+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
+}
--- /dev/null
+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
+ })
+}
--- /dev/null
+module git.earlybird.gay/today-app
+
+go 1.22.4