--- /dev/null
+// Copyright (C) 2024 early (MIT)
+package app
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "os"
+ "path"
+ "path/filepath"
+ "runtime"
+ "slices"
+ "strings"
+ "sync"
+ "sync/atomic"
+
+ "git.earlybird.gay/today-engine/include"
+ "git.earlybird.gay/today-engine/page"
+)
+
+func init() {
+ include.SetNotEligible("git.earlybird.gay/today-app/app")
+}
+
+type App struct {
+ http.Server
+ http.ServeMux
+
+ Logger *slog.Logger
+
+ static map[string]http.Handler
+ nonStaticFiles []string
+
+ running atomic.Bool
+ wg sync.WaitGroup
+ shutdown, stop chan struct{}
+
+ ready atomic.Bool
+ initLock sync.Mutex
+}
+
+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()
+ }()
+}
+
+// List of static content types for app.Static.
+// http *should* handle this, but sometimes it doesn't. Use this, then let the
+// stdlib try to sort it out otherwise.
+//
+// Source: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
+var staticContentTypes = map[string]string{
+ ".aac": "audio/aac",
+ ".abw": "application/x-abiword",
+ ".apng": "image/apng",
+ ".arc": "application/x-freearc",
+ ".avif": "image/avif",
+ ".avi": "video/x-msvideo",
+ ".azw": "application/vnd.amazon.ebook",
+ ".bin": "application/octet-stream",
+ ".bmp": "image/bmp",
+ ".bz": "application/x-bzip",
+ ".bz2": "application/x-bzip2",
+ ".cda": "application/x-cdf",
+ ".csh": "application/x-csh",
+ ".css": "text/css",
+ ".csv": "text/csv",
+ ".doc": "application/msword",
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ ".eot": "application/vnd.ms-fontobject",
+ ".epub": "application/epub+zip",
+ ".gz": "application/gzip",
+ ".gif": "image/gif",
+ ".htm": "text/html",
+ ".html": "text/html",
+ ".ico": "image/vnd.microsoft.icon",
+ ".ics": "text/calendar",
+ ".jar": "application/java-archive",
+ ".jpeg": "image/jpeg",
+ ".jpg": "image/jpeg",
+ ".js": "text/javascript",
+ ".json": "application/json",
+ ".jsonld": "application/ld+json",
+ ".mid": "audio/midi",
+ ".midi": "audio/midi",
+ ".mjs": "text/javascript",
+ ".mp3": "audio/mpeg",
+ ".mp4": "video/mp4",
+ ".mpeg": "video/mpeg",
+ ".mpkg": "application/vnd.apple.installer+xml",
+ ".odp": "application/vnd.oasis.opendocument.presentation",
+ ".ods": "application/vnd.oasis.opendocument.spreadsheet",
+ ".odt": "application/vnd.oasis.opendocument.text",
+ ".oga": "audio/ogg",
+ ".ogv": "video/ogg",
+ ".ogx": "application/ogg",
+ ".opus": "audio/ogg",
+ ".otf": "font/otf",
+ ".png": "image/png",
+ ".pdf": "application/pdf",
+ ".php": "application/x-httpd-php",
+ ".ppt": "application/vnd.ms-powerpoint",
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ ".rar": "application/vnd.rar",
+ ".rtf": "application/rtf",
+ ".sh": "application/x-sh",
+ ".svg": "image/svg+xml",
+ ".tar": "application/x-tar",
+ ".tif": "image/tiff",
+ ".tiff": "image/tiff",
+ ".ts": "video/mp2t",
+ ".ttf": "font/ttf",
+ ".txt": "text/plain",
+ ".vsd": "application/vnd.visio",
+ ".wav": "audio/wav",
+ ".weba": "audio/webm",
+ ".webm": "video/webm",
+ ".webp": "image/webp",
+ ".woff": "font/woff",
+ ".woff2": "font/woff2",
+ ".xhtml": "application/xhtml+xml",
+ ".xls": "application/vnd.ms-excel",
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ ".xml": "application/xml",
+ ".xul": "application/vnd.mozilla.xul+xml",
+ ".zip": "application/zip",
+ ".7z": "application/x-7z-compressed",
+}
+
+// Static serves the files in rootDir at rootPath.
+//
+// - .html files have their extensions stripped, and are served as their
+// filenames, following a directory structure. If there is an index.html
+// in a directory, it is served under the directory name, with a trailing
+// slash, so /foo/bar/index.html is served at /foo/bar/.
+// - Other non-excluded files are served with their extensions intact.
+// Currently, .go files and anything in a hidden directory are excluded.
+// MIME types are chosen by Go's standard HTTP library behavior until that
+// becomes a pain.
+// - If using today-engine, page.New and page.Static will *override* the
+// default behavior of Static. *This is only possible when using
+// today-engine.*
+// - If static is called twice for the same rootPath, the second call will
+// override the first.
+func (app *App) Static(rootPath, rootDir string) error {
+ if app.static == nil {
+ app.static = make(map[string]http.Handler)
+ }
+ _, relativeTo, _, _ := runtime.Caller(1)
+ if !path.IsAbs(rootDir) {
+ rootDir = path.Join(path.Dir(relativeTo), rootDir)
+ }
+ var f func(fpath string) error
+ f = func(fpath string) error {
+ stat, err := os.Stat(fpath)
+ if err != nil {
+ return err
+ }
+ if stat.IsDir() {
+ // Recurse to subdirectories
+ entries, err := os.ReadDir(fpath)
+ if err != nil {
+ return err
+ }
+ for _, entry := range entries {
+ err = errors.Join(err, f(filepath.Join(fpath, entry.Name())))
+ }
+ return err
+ } else {
+ // Serve file
+ fileName := filepath.Base(fpath)
+ fileExt := filepath.Ext(fileName)
+ servePath := filepath.Join(rootPath, strings.TrimPrefix(fpath, rootDir))
+ var handler http.Handler
+ switch fileExt {
+ case ".go":
+ // explicit ignore .go
+ handler = nil
+ case ".html":
+ if fileName == "index.html" {
+ servePath = path.Clean(path.Dir(servePath) + "/{$}")
+ } else {
+ servePath = strings.TrimSuffix(servePath, ".html")
+ }
+ handler = page.Static(fpath)
+ default:
+ handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ f, err := os.Open(fpath)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ fmt.Fprintln(w, "failed to open file")
+ return
+ }
+ if contentType, ok := staticContentTypes[fileExt]; ok {
+ w.Header().Set("Content-Type", contentType)
+ }
+ w.WriteHeader(http.StatusOK)
+ io.Copy(w, f)
+ })
+ }
+
+ // If not ignoring the file, handle it.
+ if handler != nil {
+ serveExpr := "GET " + servePath
+ app.static[serveExpr] = handler
+ }
+ return nil
+ }
+ }
+ return f(rootDir)
+}
+
+func (app *App) Handle(expr string, handler http.Handler) {
+ // Check page handlers against static
+ if todayPage, ok := handler.(*page.Page); ok {
+ app.nonStaticFiles = append(app.nonStaticFiles, todayPage.FileDependencies()...)
+ }
+ app.ServeMux.Handle(expr, handler)
+}
+
+func (app *App) registerStatic() {
+registerStaticHandlers:
+ for expr, staticHandler := range app.static {
+ // If the staticHandler is a *page.Page,
+ if staticPage, ok := staticHandler.(*page.Page); ok {
+ // get the static file dependencies (should just be the static file)
+ staticFile := staticPage.FileDependencies()[0]
+ // Make sure it isn't overridden by an explicitly handled *page.Page.
+ if slices.Contains(app.nonStaticFiles, staticFile) {
+ continue registerStaticHandlers
+ }
+ }
+ app.Handle(expr, staticHandler)
+ }
+}
+
+func (app *App) initOnce() {
+ // Only let one through
+ // can't use CAS because we need non-init processes to wait
+ if app.ready.Load() {
+ return
+ }
+ app.initLock.Lock()
+ if app.ready.Load() {
+ app.initLock.Unlock()
+ return
+ }
+ app.setDefaults()
+ app.registerStatic()
+
+ app.ready.Store(true)
+ app.running.Store(true)
+ app.initLock.Unlock()
+}
+
+func (app *App) ListenAndServe() error {
+ app.initOnce()
+ 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
+ }
+
+ 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.initOnce()
+ 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
-// Copyright (C) 2024 early (MIT)
-package app
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "log/slog"
- "net"
- "net/http"
- "os"
- "path"
- "path/filepath"
- "runtime"
- "slices"
- "strings"
- "sync"
- "sync/atomic"
-
- "git.earlybird.gay/today-engine/include"
- "git.earlybird.gay/today-engine/page"
-)
-
-func init() {
- include.SetNotEligible("git.earlybird.gay/today-app/app")
-}
-
-type App struct {
- http.Server
- http.ServeMux
-
- Logger *slog.Logger
-
- static map[string]http.Handler
- nonStaticFiles []string
-
- running atomic.Bool
- wg sync.WaitGroup
- shutdown, stop chan struct{}
-
- ready atomic.Bool
- initLock sync.Mutex
-}
-
-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()
- }()
-}
-
-// List of static content types for app.Static.
-// http *should* handle this, but sometimes it doesn't. Use this, then let the
-// stdlib try to sort it out otherwise.
-//
-// Source: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
-var staticContentTypes = map[string]string{
- ".aac": "audio/aac",
- ".abw": "application/x-abiword",
- ".apng": "image/apng",
- ".arc": "application/x-freearc",
- ".avif": "image/avif",
- ".avi": "video/x-msvideo",
- ".azw": "application/vnd.amazon.ebook",
- ".bin": "application/octet-stream",
- ".bmp": "image/bmp",
- ".bz": "application/x-bzip",
- ".bz2": "application/x-bzip2",
- ".cda": "application/x-cdf",
- ".csh": "application/x-csh",
- ".css": "text/css",
- ".csv": "text/csv",
- ".doc": "application/msword",
- ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
- ".eot": "application/vnd.ms-fontobject",
- ".epub": "application/epub+zip",
- ".gz": "application/gzip",
- ".gif": "image/gif",
- ".htm": "text/html",
- ".html": "text/html",
- ".ico": "image/vnd.microsoft.icon",
- ".ics": "text/calendar",
- ".jar": "application/java-archive",
- ".jpeg": "image/jpeg",
- ".jpg": "image/jpeg",
- ".js": "text/javascript",
- ".json": "application/json",
- ".jsonld": "application/ld+json",
- ".mid": "audio/midi",
- ".midi": "audio/midi",
- ".mjs": "text/javascript",
- ".mp3": "audio/mpeg",
- ".mp4": "video/mp4",
- ".mpeg": "video/mpeg",
- ".mpkg": "application/vnd.apple.installer+xml",
- ".odp": "application/vnd.oasis.opendocument.presentation",
- ".ods": "application/vnd.oasis.opendocument.spreadsheet",
- ".odt": "application/vnd.oasis.opendocument.text",
- ".oga": "audio/ogg",
- ".ogv": "video/ogg",
- ".ogx": "application/ogg",
- ".opus": "audio/ogg",
- ".otf": "font/otf",
- ".png": "image/png",
- ".pdf": "application/pdf",
- ".php": "application/x-httpd-php",
- ".ppt": "application/vnd.ms-powerpoint",
- ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
- ".rar": "application/vnd.rar",
- ".rtf": "application/rtf",
- ".sh": "application/x-sh",
- ".svg": "image/svg+xml",
- ".tar": "application/x-tar",
- ".tif": "image/tiff",
- ".tiff": "image/tiff",
- ".ts": "video/mp2t",
- ".ttf": "font/ttf",
- ".txt": "text/plain",
- ".vsd": "application/vnd.visio",
- ".wav": "audio/wav",
- ".weba": "audio/webm",
- ".webm": "video/webm",
- ".webp": "image/webp",
- ".woff": "font/woff",
- ".woff2": "font/woff2",
- ".xhtml": "application/xhtml+xml",
- ".xls": "application/vnd.ms-excel",
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- ".xml": "application/xml",
- ".xul": "application/vnd.mozilla.xul+xml",
- ".zip": "application/zip",
- ".7z": "application/x-7z-compressed",
-}
-
-// Static serves the files in rootDir at rootPath.
-//
-// - .html files have their extensions stripped, and are served as their
-// filenames, following a directory structure. If there is an index.html
-// in a directory, it is served under the directory name, with a trailing
-// slash, so /foo/bar/index.html is served at /foo/bar/.
-// - Other non-excluded files are served with their extensions intact.
-// Currently, .go files and anything in a hidden directory are excluded.
-// MIME types are chosen by Go's standard HTTP library behavior until that
-// becomes a pain.
-// - If using today-engine, page.New and page.Static will *override* the
-// default behavior of Static. *This is only possible when using
-// today-engine.*
-// - If static is called twice for the same rootPath, the second call will
-// override the first.
-func (app *App) Static(rootPath, rootDir string) error {
- if app.static == nil {
- app.static = make(map[string]http.Handler)
- }
- _, relativeTo, _, _ := runtime.Caller(1)
- if !path.IsAbs(rootDir) {
- rootDir = path.Join(path.Dir(relativeTo), rootDir)
- }
- var f func(fpath string) error
- f = func(fpath string) error {
- stat, err := os.Stat(fpath)
- if err != nil {
- return err
- }
- if stat.IsDir() {
- // Recurse to subdirectories
- entries, err := os.ReadDir(fpath)
- if err != nil {
- return err
- }
- for _, entry := range entries {
- err = errors.Join(err, f(filepath.Join(fpath, entry.Name())))
- }
- return err
- } else {
- // Serve file
- fileName := filepath.Base(fpath)
- fileExt := filepath.Ext(fileName)
- servePath := filepath.Join(rootPath, strings.TrimPrefix(fpath, rootDir))
- var handler http.Handler
- switch fileExt {
- case ".go":
- // explicit ignore .go
- handler = nil
- case ".html":
- if fileName == "index.html" {
- servePath = path.Clean(path.Dir(servePath) + "/{$}")
- } else {
- servePath = strings.TrimSuffix(servePath, ".html")
- }
- handler = page.Static(fpath)
- default:
- handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- f, err := os.Open(fpath)
- if err != nil {
- w.WriteHeader(http.StatusInternalServerError)
- fmt.Fprintln(w, "failed to open file")
- return
- }
- if contentType, ok := staticContentTypes[fileExt]; ok {
- w.Header().Set("Content-Type", contentType)
- }
- w.WriteHeader(http.StatusOK)
- io.Copy(w, f)
- })
- }
-
- // If not ignoring the file, handle it.
- if handler != nil {
- serveExpr := "GET " + servePath
- app.static[serveExpr] = handler
- }
- return nil
- }
- }
- return f(rootDir)
-}
-
-func (app *App) Handle(expr string, handler http.Handler) {
- // Check page handlers against static
- if todayPage, ok := handler.(*page.Page); ok {
- app.nonStaticFiles = append(app.nonStaticFiles, todayPage.FileDependencies()...)
- }
- app.ServeMux.Handle(expr, handler)
-}
-
-func (app *App) registerStatic() {
-registerStaticHandlers:
- for expr, staticHandler := range app.static {
- // If the staticHandler is a *page.Page,
- if staticPage, ok := staticHandler.(*page.Page); ok {
- // get the static file dependencies (should just be the static file)
- staticFile := staticPage.FileDependencies()[0]
- // Make sure it isn't overridden by an explicitly handled *page.Page.
- if slices.Contains(app.nonStaticFiles, staticFile) {
- continue registerStaticHandlers
- }
- }
- app.Handle(expr, staticHandler)
- }
-}
-
-func (app *App) initOnce() {
- // Only let one through
- // can't use CAS because we need non-init processes to wait
- if app.ready.Load() {
- return
- }
- app.initLock.Lock()
- if app.ready.Load() {
- app.initLock.Unlock()
- return
- }
- app.setDefaults()
- app.registerStatic()
-
- app.ready.Store(true)
- app.running.Store(true)
- app.initLock.Unlock()
-}
-
-func (app *App) ListenAndServe() error {
- app.initOnce()
- 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
- }
-
- 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.initOnce()
- 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
-// Copyright (C) 2024 early (MIT)
-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
-// Copyright (C) 2024 early (MIT)
-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
+// Copyright (C) 2024 early (MIT)
+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
+// Copyright (C) 2024 early (MIT)
+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
+ })
+}