From: early Date: Sat, 2 Nov 2024 18:22:14 +0000 (-0600) Subject: Add 'app/' from commit 'd1c3413a0e1237be0193e9bc3e8651f2390f6b0a' X-Git-Url: https://git.earlybird.gay/?a=commitdiff_plain;h=a4b4db1b57a547474699d6c3213aa9e6663709cc;p=today Add 'app/' from commit 'd1c3413a0e1237be0193e9bc3e8651f2390f6b0a' git-subtree-dir: app git-subtree-mainline: f456affad5bc733082e176449dc2d641e5c3bb33 git-subtree-split: d1c3413a0e1237be0193e9bc3e8651f2390f6b0a --- a4b4db1b57a547474699d6c3213aa9e6663709cc diff --cc app/LICENSE.txt index 0000000,0000000..a8876ed new file mode 100644 --- /dev/null +++ b/app/LICENSE.txt @@@ -1,0 -1,0 +1,18 @@@ ++Copyright © 2024 Early ++ ++Permission is hereby granted, free of charge, to any person obtaining a copy of ++this software and associated documentation files (the “Software”), to deal in ++the Software without restriction, including without limitation the rights to ++use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of ++the Software, and to permit persons to whom the Software is furnished to do so, ++subject to the following conditions: ++ ++The above copyright notice and this permission notice shall be included in all ++copies or substantial portions of the Software. ++ ++THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR ++IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS ++FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR ++COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER ++IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN ++CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --cc app/README index 0000000,0000000..419f9f8 new file mode 100644 --- /dev/null +++ b/app/README @@@ -1,0 -1,0 +1,24 @@@ ++===== Today Web Apps =========================================================== ++ ++This respository contains all the code and applications related to the Today web ++app framework. ++ ++Author: Early N. ++License: MIT ++ ++===== Usage ==================================================================== ++ ++go get git.earlybird.gay/today-app@latest ++ ++===== 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. ++3. (Done!) Subprocess management. It should be pretty easy to implement a ++ reponsible subprocess that knows when it should stop and clean up after ++ itself. ++4. (Done!) Static support. diff --cc app/app/app.go index 0000000,0000000..ccf1b75 new file mode 100644 --- /dev/null +++ b/app/app/app.go @@@ -1,0 -1,0 +1,338 @@@ ++// 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") ++} diff --cc app/app/env.go index 0000000,0000000..c593f4d new file mode 100644 --- /dev/null +++ b/app/app/env.go @@@ -1,0 -1,0 +1,82 @@@ ++// 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 ++} diff --cc app/app/process.go index 0000000,0000000..6bc5bd9 new file mode 100644 --- /dev/null +++ b/app/app/process.go @@@ -1,0 -1,0 +1,25 @@@ ++// 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 ++ }) ++} diff --cc app/go.mod index 0000000,0000000..18c43e4 new file mode 100644 --- /dev/null +++ b/app/go.mod @@@ -1,0 -1,0 +1,5 @@@ ++module git.earlybird.gay/today-app ++ ++go 1.22.4 ++ ++require git.earlybird.gay/today-engine v0.0.0-20240911045033-55d49a64189f diff --cc app/go.sum index 0000000,0000000..6423552 new file mode 100644 --- /dev/null +++ b/app/go.sum @@@ -1,0 -1,0 +1,2 @@@ ++git.earlybird.gay/today-engine v0.0.0-20240911045033-55d49a64189f h1:vyQTIzkDUvqO3coZArw+jiERQ0xzLVjaZx6n6lIpOxQ= ++git.earlybird.gay/today-engine v0.0.0-20240911045033-55d49a64189f/go.mod h1:9w8xpAPxs1QvT//ph/jgAuRIoWyqdi2QEifwsKWOKns=