├── go.mod ├── go.sum ├── templates └── index.html ├── README.md ├── vii_test.go ├── LICENSE └── vii.go /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Phillip-England/vii 2 | 3 | go 1.23.3 4 | 5 | require github.com/a-h/templ v0.3.857 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/a-h/templ v0.3.857 h1:6EqcJuGZW4OL+2iZ3MD+NnIcG7nGkaQeF2Zq5kf9ZGg= 2 | github.com/a-h/templ v0.3.857/go.mod h1:qhrhAkRFubE7khxLZHsBFHfX+gWwVNKbzKeF9GlPV4M= 3 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vii 2 | Minimal servers in Go 3 | 4 | ## Installation 5 | ```bash 6 | go get github.com/Phillip-England/vii 7 | ``` 8 | 9 | ## Hello, World 10 | 1. Create a new `go` project 11 | 2. Create a `./templates` dir 12 | 3. Create a `./static` dir 13 | 14 | Basic Server: 15 | 16 | ```go 17 | package main 18 | 19 | import ( 20 | "github.com/Phillip-England/vii" 21 | ) 22 | 23 | func main() { 24 | app := vii.NewApp() 25 | app.Use(vii.MwLogger, vii.MwTimeout(10)) 26 | app.Static("./static") 27 | app.Favicon() 28 | err := app.Templates("./templates", nil) 29 | if err != nil { 30 | panic(err) 31 | } 32 | app.At("GET /", func(w http.ResponseWriter, r *http.Request) { 33 | vii.ExecuteTemplate(w, r, "index.html", nil) 34 | }) 35 | app.Serve("8080") 36 | } 37 | ``` 38 | -------------------------------------------------------------------------------- /vii_test.go: -------------------------------------------------------------------------------- 1 | package vii 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | ) 7 | 8 | func TestVii(t *testing.T) { 9 | 10 | app := NewApp() 11 | 12 | err := app.Templates("./templates", nil) 13 | if err != nil { 14 | panic(err) 15 | } 16 | 17 | app.Use(MwLogger) 18 | 19 | app.Static("./static") 20 | app.Favicon() 21 | 22 | app.At("GET /", func(w http.ResponseWriter, r *http.Request) { 23 | ExecuteTemplate(w, r, "index.html", nil) 24 | }, MwLogger, MwTimeout(10)) 25 | 26 | apiGroup := app.Group("/api") 27 | apiGroup.Use(MwLogger) 28 | 29 | apiGroup.At("GET /", func(w http.ResponseWriter, r *http.Request) { 30 | app.JSON(w, 200, map[string]interface{}{ 31 | "message": "Hello, World!", 32 | }) 33 | }, MwLogger, MwTimeout(10)) 34 | 35 | app.Serve("8080") 36 | 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Phillip England 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /vii.go: -------------------------------------------------------------------------------- 1 | package vii 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "html/template" 8 | "io/fs" // [NEW] Required for handling embedded filesystems 9 | "net/http" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | "time" 14 | 15 | "github.com/a-h/templ" 16 | ) 17 | 18 | type Group struct { 19 | parent *App 20 | prefix string 21 | middleware []func(http.Handler) http.Handler 22 | } 23 | 24 | func (app *App) Group(prefix string) *Group { 25 | return &Group{ 26 | parent: app, 27 | prefix: strings.TrimRight(prefix, "/"), 28 | middleware: []func(http.Handler) http.Handler{}, 29 | } 30 | } 31 | 32 | func (g *Group) Use(middleware ...func(http.Handler) http.Handler) { 33 | g.middleware = append(g.middleware, middleware...) 34 | } 35 | 36 | func (g *Group) At(path string, handler http.HandlerFunc, middleware ...func(http.Handler) http.Handler) { 37 | resolvedPath := g.prefix + strings.TrimRight(strings.Split(path, " ")[1], "/") 38 | method := strings.Split(path, " ")[0] 39 | // Only apply Group + Local middleware here 40 | allMiddleware := append(g.middleware, middleware...) 41 | g.parent.Mux.HandleFunc(method+" "+resolvedPath, func(w http.ResponseWriter, r *http.Request) { 42 | r = SetContext("GLOBAL", g.parent.GlobalContext, r) 43 | chain(handler, allMiddleware...).ServeHTTP(w, r) 44 | }) 45 | } 46 | 47 | //===================================== 48 | // app 49 | //===================================== 50 | 51 | const VII_CONTEXT = "VII_CONTEXT" 52 | 53 | type App struct { 54 | Mux *http.ServeMux 55 | GlobalContext map[string]any 56 | GlobalMiddleware []func(http.Handler) http.Handler 57 | } 58 | 59 | func NewApp() App { 60 | mux := http.NewServeMux() 61 | app := App{ 62 | Mux: mux, 63 | GlobalContext: make(map[string]any), 64 | GlobalMiddleware: []func(http.Handler) http.Handler{}, 65 | } 66 | return app 67 | } 68 | 69 | func (app *App) Use(middleware ...func(http.Handler) http.Handler) { 70 | app.GlobalMiddleware = append(app.GlobalMiddleware, middleware...) 71 | } 72 | 73 | func (app *App) SetContext(key string, value any) { 74 | app.GlobalContext[key] = value 75 | } 76 | 77 | func (app *App) At(path string, handler http.HandlerFunc, middleware ...func(http.Handler) http.Handler) { 78 | app.Mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { 79 | r = SetContext("GLOBAL", app.GlobalContext, r) 80 | // Only apply Local middleware here 81 | chain(handler, middleware...).ServeHTTP(w, r) 82 | }) 83 | } 84 | 85 | // Templates loads templates from disk (Legacy) 86 | func (app *App) Templates(path string, funcMap template.FuncMap) error { 87 | strEquals := func(input string, value string) bool { 88 | return input == value 89 | } 90 | vbfFuncMap := template.FuncMap{ 91 | "strEquals": strEquals, 92 | } 93 | for k, v := range funcMap { 94 | vbfFuncMap[k] = v 95 | } 96 | templates := template.New("").Funcs(vbfFuncMap) 97 | err := filepath.Walk(path, func(path string, info os.FileInfo, err error) error { 98 | if err != nil { 99 | return err 100 | } 101 | if !info.IsDir() && filepath.Ext(path) == ".html" { 102 | _, err := templates.ParseFiles(path) 103 | if err != nil { 104 | return err 105 | } 106 | } 107 | return nil 108 | }) 109 | if err != nil { 110 | return err 111 | } 112 | app.SetContext(VII_CONTEXT, templates) 113 | return nil 114 | } 115 | 116 | // TemplatesFS loads templates from an embedded filesystem (NEW) 117 | // patterns example: "templates/*.html" or "templates/**/*.html" 118 | func (app *App) TemplatesFS(fileSystem fs.FS, patterns string, funcMap template.FuncMap) error { 119 | strEquals := func(input string, value string) bool { 120 | return input == value 121 | } 122 | vbfFuncMap := template.FuncMap{ 123 | "strEquals": strEquals, 124 | } 125 | for k, v := range funcMap { 126 | vbfFuncMap[k] = v 127 | } 128 | 129 | // ParseFS handles the walking and matching of patterns natively 130 | templates, err := template.New("").Funcs(vbfFuncMap).ParseFS(fileSystem, patterns) 131 | if err != nil { 132 | return err 133 | } 134 | 135 | app.SetContext(VII_CONTEXT, templates) 136 | return nil 137 | } 138 | 139 | func (app *App) Favicon(middleware ...func(http.Handler) http.Handler) { 140 | app.Mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) { 141 | chain(func(w http.ResponseWriter, r *http.Request) { 142 | filePath := "favicon.ico" 143 | fullPath := filepath.Join(".", ".", filePath) 144 | http.ServeFile(w, r, fullPath) 145 | }, middleware...).ServeHTTP(w, r) 146 | }) 147 | } 148 | 149 | // Static serves files from disk (Legacy) 150 | func (app *App) Static(path string, middleware ...func(http.Handler) http.Handler) { 151 | staticPath := strings.TrimRight(path, "/") 152 | fileServer := http.FileServer(http.Dir(staticPath)) 153 | stripHandler := http.StripPrefix("/"+filepath.Base(staticPath)+"/", fileServer) 154 | var handler http.Handler = stripHandler 155 | if len(middleware) > 0 { 156 | handler = chain(stripHandler.ServeHTTP, middleware...) 157 | } 158 | app.Mux.Handle("GET /"+filepath.Base(staticPath)+"/", handler) 159 | } 160 | 161 | // StaticEmbed serves files from an embedded filesystem (NEW) 162 | // urlPrefix: the URL path to serve from (e.g., "/static") 163 | // fileSystem: the embedded FS (e.g., staticFS) 164 | func (app *App) StaticEmbed(urlPrefix string, fileSystem fs.FS, middleware ...func(http.Handler) http.Handler) { 165 | // Ensure the prefix is clean 166 | urlPrefix = "/" + strings.Trim(urlPrefix, "/") + "/" 167 | 168 | // Convert fs.FS to http.FileSystem 169 | fileServer := http.FileServer(http.FS(fileSystem)) 170 | 171 | // Strip the prefix from the request URL so the file server sees the relative path 172 | stripHandler := http.StripPrefix(urlPrefix, fileServer) 173 | 174 | var handler http.Handler = stripHandler 175 | if len(middleware) > 0 { 176 | handler = chain(stripHandler.ServeHTTP, middleware...) 177 | } 178 | 179 | app.Mux.Handle("GET "+urlPrefix, handler) 180 | } 181 | 182 | func (app *App) JSON(w http.ResponseWriter, status int, data interface{}) error { 183 | w.Header().Set("Content-Type", "application/json") 184 | w.WriteHeader(status) 185 | jsonData, err := json.Marshal(data) 186 | if err != nil { 187 | return err 188 | } 189 | _, err = w.Write(jsonData) 190 | return err 191 | } 192 | 193 | func (app *App) HTML(w http.ResponseWriter, status int, html string) error { 194 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 195 | w.WriteHeader(status) 196 | _, err := w.Write([]byte(html)) 197 | return err 198 | } 199 | 200 | func (app *App) Text(w http.ResponseWriter, status int, text string) error { 201 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") 202 | w.WriteHeader(status) 203 | _, err := w.Write([]byte(text)) 204 | return err 205 | } 206 | 207 | func (app *App) Serve(port string) error { 208 | fmt.Println("starting server on port " + port + " 🚀") 209 | 210 | finalHandler := chain(app.Mux.ServeHTTP, app.GlobalMiddleware...) 211 | 212 | err := http.ListenAndServe(":"+port, finalHandler) 213 | if err != nil { 214 | return err 215 | } 216 | return nil 217 | } 218 | 219 | //===================================== 220 | // middleware 221 | //===================================== 222 | 223 | func chain(h http.HandlerFunc, middleware ...func(http.Handler) http.Handler) http.Handler { 224 | finalHandler := http.Handler(h) 225 | for _, m := range middleware { 226 | finalHandler = m(finalHandler) 227 | } 228 | return finalHandler 229 | } 230 | 231 | func MwTimeout(seconds int) func(http.Handler) http.Handler { 232 | return func(next http.Handler) http.Handler { 233 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 234 | done := make(chan bool) 235 | ctx, cancel := context.WithTimeout(r.Context(), time.Duration(seconds)*time.Second) 236 | defer cancel() 237 | r = r.WithContext(ctx) 238 | go func() { 239 | next.ServeHTTP(w, r) 240 | select { 241 | case <-ctx.Done(): 242 | return 243 | case done <- true: 244 | } 245 | }() 246 | select { 247 | case <-done: 248 | return 249 | case <-ctx.Done(): 250 | http.Error(w, "Request timed out", http.StatusGatewayTimeout) 251 | return 252 | } 253 | }) 254 | } 255 | } 256 | 257 | func MwLogger(next http.Handler) http.Handler { 258 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 259 | startTime := time.Now() 260 | next.ServeHTTP(w, r) 261 | endTime := time.Since(startTime) 262 | fmt.Printf("[%s][%s][%s]\n", r.Method, r.URL.Path, endTime) 263 | }) 264 | } 265 | 266 | func MwCORS(next http.Handler) http.Handler { 267 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 268 | origin := r.Header.Get("Origin") 269 | if origin != "" { 270 | w.Header().Set("Access-Control-Allow-Origin", origin) 271 | } else { 272 | w.Header().Set("Access-Control-Allow-Origin", "*") 273 | } 274 | w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") 275 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") 276 | w.Header().Set("Access-Control-Allow-Credentials", "true") 277 | if r.Method == "OPTIONS" { 278 | w.WriteHeader(http.StatusOK) 279 | return 280 | } 281 | next.ServeHTTP(w, r) 282 | }) 283 | } 284 | 285 | //===================================== 286 | // context 287 | //===================================== 288 | 289 | type ContextKey string 290 | 291 | func SetContext(key string, val any, r *http.Request) *http.Request { 292 | ctx := context.WithValue(r.Context(), ContextKey(key), val) 293 | r = r.WithContext(ctx) 294 | return r 295 | } 296 | 297 | func GetContext(key string, r *http.Request) any { 298 | ctxMap, ok := r.Context().Value(ContextKey("GLOBAL")).(map[string]any) 299 | if ok { 300 | mapVal := ctxMap[key] 301 | if mapVal != nil { 302 | return mapVal 303 | } 304 | } 305 | val := r.Context().Value(ContextKey(key)) 306 | return val 307 | } 308 | 309 | //===================================== 310 | // templating 311 | //===================================== 312 | 313 | func getTemplates(r *http.Request) *template.Template { 314 | templates, _ := GetContext(VII_CONTEXT, r).(*template.Template) 315 | return templates 316 | } 317 | 318 | func ExecuteTemplate(w http.ResponseWriter, r *http.Request, filepath string, data any) error { 319 | w.Header().Add("Content-Type", "text/html") 320 | templates := getTemplates(r) 321 | err := templates.ExecuteTemplate(w, filepath, data) 322 | if err != nil { 323 | return err 324 | } 325 | return nil 326 | } 327 | 328 | //===================================== 329 | // request / response helpers 330 | //===================================== 331 | 332 | func Param(r *http.Request, paramName string) string { 333 | return r.URL.Query().Get(paramName) 334 | } 335 | 336 | func ParamIs(r *http.Request, paramName string, valueToCheck string) bool { 337 | return r.URL.Query().Get(paramName) == valueToCheck 338 | } 339 | 340 | func WriteHTML(w http.ResponseWriter, status int, content string) { 341 | w.Header().Set("Content-Type", "text/html") 342 | w.WriteHeader(status) 343 | w.Write([]byte(content)) 344 | } 345 | 346 | func WriteString(w http.ResponseWriter, status int, content string) { 347 | w.Header().Set("Content-Type", "text/plain") 348 | w.WriteHeader(status) 349 | w.Write([]byte(content)) 350 | } 351 | 352 | func WriteJSON(w http.ResponseWriter, statusCode int, data interface{}) error { 353 | w.Header().Set("Content-Type", "application/json") 354 | w.WriteHeader(statusCode) 355 | 356 | err := json.NewEncoder(w).Encode(data) 357 | if err != nil { 358 | http.Error(w, err.Error(), http.StatusInternalServerError) 359 | return err 360 | } 361 | return nil 362 | } 363 | 364 | func WriteTempl(w http.ResponseWriter, r *http.Request, component templ.Component) error { 365 | w.Header().Add("Content-Type", "text/html") 366 | err := component.Render(r.Context(), w) 367 | if err != nil { 368 | return err 369 | } 370 | return nil 371 | } 372 | --------------------------------------------------------------------------------