]> git.earlybird.gay Git - today/commitdiff
Add 'app/' from commit 'd1c3413a0e1237be0193e9bc3e8651f2390f6b0a'
authorearly <me@earlybird.gay>
Sat, 2 Nov 2024 18:22:14 +0000 (12:22 -0600)
committerearly <me@earlybird.gay>
Sat, 2 Nov 2024 18:22:14 +0000 (12:22 -0600)
git-subtree-dir: app
git-subtree-mainline: f456affad5bc733082e176449dc2d641e5c3bb33
git-subtree-split: d1c3413a0e1237be0193e9bc3e8651f2390f6b0a

1  2 
app/LICENSE.txt
app/README
app/app/app.go
app/app/env.go
app/app/process.go
app/go.mod
app/go.sum

diff --cc app/LICENSE.txt
index 0000000000000000000000000000000000000000,0000000000000000000000000000000000000000..a8876ed210f4173fd04a8b41a7f6522ea2dd1a64
new file mode 100644 (file)
--- /dev/null
--- /dev/null
@@@ -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 0000000000000000000000000000000000000000,0000000000000000000000000000000000000000..419f9f89080502acd5446ee790bac8a166add215
new file mode 100644 (file)
--- /dev/null
--- /dev/null
@@@ -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 0000000000000000000000000000000000000000,0000000000000000000000000000000000000000..ccf1b75d8bcdfbc75daaec4a84cfacf37f9b94b0
new file mode 100644 (file)
--- /dev/null
--- /dev/null
@@@ -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 0000000000000000000000000000000000000000,0000000000000000000000000000000000000000..c593f4d34e8b1acf118e49c375500bad1823cdcc
new file mode 100644 (file)
--- /dev/null
--- /dev/null
@@@ -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
++}
index 0000000000000000000000000000000000000000,0000000000000000000000000000000000000000..6bc5bd997da72fd38364d48aa81e3a2ba829b761
new file mode 100644 (file)
--- /dev/null
--- /dev/null
@@@ -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 0000000000000000000000000000000000000000,0000000000000000000000000000000000000000..18c43e4af5ad042ae10c3d10dbbefec9b021c3ae
new file mode 100644 (file)
--- /dev/null
--- /dev/null
@@@ -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 0000000000000000000000000000000000000000,0000000000000000000000000000000000000000..6423552cb892ed8ce8a41d7554aa5680d1b1265d
new file mode 100644 (file)
--- /dev/null
--- /dev/null
@@@ -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=