--- /dev/null
--- /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
--- /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
++}