From: early Date: Tue, 13 Aug 2024 00:16:50 +0000 (-0600) Subject: Initial commit X-Git-Url: https://git.earlybird.gay/?a=commitdiff_plain;h=ea593aafc869b8cfbf95e36c98f85a69f19b1dc6;p=today Initial commit --- ea593aafc869b8cfbf95e36c98f85a69f19b1dc6 diff --git a/README b/README new file mode 100644 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 index 0000000..8ab8a78 --- /dev/null +++ b/app/app.go @@ -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 index 0000000..7524b5d --- /dev/null +++ b/app/env.go @@ -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 index 0000000..d6ed116 --- /dev/null +++ b/app/process.go @@ -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 index 0000000..a2595c8 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.earlybird.gay/today-app + +go 1.22.4