app.go
177 lines1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
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
healthPath string // Path for auto-registered health endpoint (default: "/health")
// ScriptFunc generates the frontend <script> tags with a nonce.
// Set by the frontend controller; called per-request in render().
ScriptFunc func(nonce string) template.HTML
}
// 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.
// Validates the method exists and has the correct signature at registration time.
func (app *App) Method(controller any, methodName string, bouncer Bouncer) http.Handler {
method := reflect.ValueOf(controller).MethodByName(methodName)
if !method.IsValid() {
log.Fatalf("Method %q not found on %T", methodName, controller)
}
// Validate signature: func(http.ResponseWriter, *http.Request)
mt := method.Type()
if mt.NumIn() != 2 || mt.In(0) != reflect.TypeOf((*http.ResponseWriter)(nil)).Elem() || mt.In(1) != reflect.TypeOf((*http.Request)(nil)) {
log.Fatalf("Method %q on %T must have signature (http.ResponseWriter, *http.Request), got %v", methodName, controller, mt)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if bouncer != nil && !bouncer(app, w, r) {
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)
// Register health endpoint (after all options so WithHealthPath can override)
http.HandleFunc("GET "+cmp.Or(app.healthPath, "/health"), func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// 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")
}