readysite / pkg / application / app.go
4.5 KB
app.go
package application

import (
	"cmp"
	"context"
	"embed"
	"html/template"
	"io/fs"
	"log"
	"net/http"
	"os"
	"os/signal"
	"reflect"
	"syscall"
	"time"
)

// Middleware wraps an http.Handler (e.g. for request interception).
type Middleware func(http.Handler) http.Handler

// App is the main application container
type App struct {
	Emailer
	funcs       template.FuncMap
	viewsFS     fs.FS              // Store FS for on-demand loading
	base        *template.Template // Only layouts + partials
	controllers map[string]Controller
	middlewares []Middleware
}

// Bouncer is a function that gates route access.
// Returns true to allow the request, false to block it.
type Bouncer func(app *App, w http.ResponseWriter, r *http.Request) bool

// Serve creates a View handler that renders a template
func (app *App) Serve(templateName string, bouncer Bouncer) *View {
	return &View{app, templateName, bouncer}
}

// Func registers a template function with the application.
// This must be called before templates are parsed (typically in options).
func (app *App) Func(name string, fn any) {
	app.funcs[name] = fn
}

// Controller registers a controller with the application.
// If the controller has a Setup method, it will be called.
func (app *App) Controller(name string, controller Controller) {
	app.controllers[name] = controller
	if setupper, ok := controller.(interface{ Setup(*App) }); ok {
		setupper.Setup(app)
	}
}

// Method creates a handler that calls a controller method with optional bouncer
func (app *App) Method(controller any, methodName string, bouncer Bouncer) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if bouncer != nil && !bouncer(app, w, r) {
			return
		}

		method := reflect.ValueOf(controller).MethodByName(methodName)
		if !method.IsValid() {
			http.Error(w, "Method not found: "+methodName, http.StatusInternalServerError)
			return
		}

		method.Call([]reflect.Value{reflect.ValueOf(w), reflect.ValueOf(r)})
	})
}

// New creates an App and applies the given options.
// This triggers controller Setup functions, useful for build-time tasks.
func New(opts ...Option) *App {
	app := &App{
		funcs:       template.FuncMap{},
		controllers: map[string]Controller{},
	}
	for _, opt := range opts {
		opt(app)
	}
	return app
}

// Serve starts the HTTP server with the given views and options
func Serve(views embed.FS, opts ...Option) {
	app := New(opts...)

	// Store FS for on-demand loading, parse base templates (layouts + partials only)
	app.viewsFS = views
	app.base = app.parseBaseTemplates(views)

	// Serve static files
	if staticFS, err := fs.Sub(views, "views/static"); err == nil {
		http.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
	}

	port := cmp.Or(os.Getenv("PORT"), "5000")
	tlsCert := os.Getenv("TLS_CERT")
	tlsKey := os.Getenv("TLS_KEY")

	// Build handler chain: custom middlewares innermost, logging outermost
	var handler http.Handler = http.DefaultServeMux
	for _, mw := range app.middlewares {
		handler = mw(handler)
	}
	handler = LoggingMiddleware(handler)

	// Create HTTP server
	httpServer := &http.Server{
		Addr:    "0.0.0.0:" + port,
		Handler: handler,
	}

	// Start HTTP server
	go func() {
		log.Printf("HTTP server starting on http://0.0.0.0:%s", port)
		if err := httpServer.ListenAndServe(); err != http.ErrServerClosed {
			log.Fatalf("HTTP server error: %v", err)
		}
	}()

	// Start HTTPS server if TLS configured and certs exist
	var httpsServer *http.Server
	if tlsCert != "" && tlsKey != "" {
		if _, err := os.Stat(tlsCert); err == nil {
			httpsServer = &http.Server{
				Addr:    "0.0.0.0:443",
				Handler: handler,
			}
			go func() {
				log.Printf("HTTPS server starting on https://0.0.0.0:443")
				if err := httpsServer.ListenAndServeTLS(tlsCert, tlsKey); err != http.ErrServerClosed {
					log.Fatalf("HTTPS server error: %v", err)
				}
			}()
		} else {
			log.Printf("TLS cert not found at %s, skipping HTTPS", tlsCert)
		}
	}

	// Wait for shutdown signal
	stop := make(chan os.Signal, 1)
	signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
	<-stop

	log.Println("Shutting down gracefully...")

	// Give in-flight requests 30 seconds to complete
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	if err := httpServer.Shutdown(ctx); err != nil {
		log.Printf("HTTP shutdown error: %v", err)
	}
	if httpsServer != nil {
		if err := httpsServer.Shutdown(ctx); err != nil {
			log.Printf("HTTPS shutdown error: %v", err)
		}
	}

	log.Println("Server stopped")
}
← Back