# go-kite A fast, lightweight, and expressive HTTP framework for Go, built on top of [httprouter](https://github.com/julienschmidt/httprouter). > **~132,000 req/sec** · **sub-4ms latency** · **~58MB RAM at peak load** ## Features - **Fast** — ~132k RPS, sitting just below raw `net/http` and above Gin, Echo, and Chi - **Middleware** — global, group, and route-level middleware with correct onion ordering - **Groups** — nestable route groups with prefix and middleware inheritance - **Static files & SPA** — serve assets and single-page apps with optional middleware - **Graceful shutdown** — in-flight requests finish cleanly on `SIGTERM` / `SIGINT` - **Configurable** — server timeouts, error/not-found/method-not-allowed handlers - **Rich context** — typed request/response helpers, value store, cookie and form support - **Built-in middleware** — logger, recovery, CORS, request ID, max body size - **WebSocket & SSE ready** — response writer implements `Hijacker`, `Flusher`, and `Pusher` ## Installation ```bash go get git.trcreatives.at/trcreatives/go-kite ``` ## Quickstart ```go package main import ( "net/http" "log" "git.trcreatives.at/trcreatives/go-kite" "git.trcreatives.at/trcreatives/go-kite/middleware" ) func main() { k := kite.New() k.Use( middleware.Logger(), // first, so it observes the final status after Recovery middleware.Recovery(), middleware.RequestID(), ) k.GET("/", func(ctx *kite.Context) error { return ctx.WriteJSON(http.StatusOK, map[string]string{ "message": "Hello, World!", }) }) if err := k.Start("localhost:8080"); err != nil { log.Fatal(err) } } ``` ## Routing ```go k := kite.New() k.GET("/users", listUsers) k.POST("/users", createUser) k.GET("/users/:id", getUser) k.PUT("/users/:id", updateUser) k.DELETE("/users/:id", deleteUser) ``` Supported HTTP methods: `GET`, `POST`, `PUT`, `DELETE`, `HEAD`, `OPTIONS`, `CONNECT`, `TRACE`. ## Route Groups Groups allow you to share a common prefix and middleware across multiple routes. ```go api := k.Group("/api") api.GET("/users", listUsers) // GET /api/users api.POST("/users", createUser) // POST /api/users ``` Groups can be nested: ```go api := k.Group("/api") v1 := api.Group("/v1") v1.GET("/users", listUsers) // GET /api/v1/users ``` Middleware can be attached to groups: ```go api := k.Group("/api", authMiddleware) admin := api.Group("/admin", adminOnlyMiddleware) admin.GET("/stats", getStats) // chain: global → auth → adminOnly → handler ``` ## Static Files & Single-Page Apps ### Static Assets `Static` serves files from a directory on disk under a URL prefix: ```go k.Static("/assets", "./public") // GET /assets/css/main.css → ./public/css/main.css ``` Middleware can be applied per-prefix: ```go k.Static("/admin/assets", "./admin-dist", authMiddleware) ``` `StaticFS` is the same but takes an `http.FileSystem`, useful for embedded assets via `embed`: ```go //go:embed dist var distFS embed.FS k.StaticFS("/assets", http.FS(distFS)) ``` Both are also available on `Group` for prefixed/scoped serving. ### Single-Page Applications For Vue, React, Svelte, and similar frameworks, `SPA` serves the directory and falls back to `index.html` for unknown paths — letting client-side routing work correctly: ```go k.SPA("./dist") ``` `SPAFS` takes an `http.FileSystem` for embedded builds. > **Note:** `SPA` and `SPAFS` register the file server as the NotFound handler. If you've already called `SetNotFoundHandler` (or call it afterward), a warning is logged about the override. ## Middleware Middleware follows the standard onion model — each middleware wraps the next. ```go type Middleware func(h Handler) Handler ``` ### Global Middleware Applied to every request, including NotFound, MethodNotAllowed, and OPTIONS preflights: ```go k.Use(middleware.Logger()) k.Use(middleware.Recovery()) ``` ### Route Middleware Applied to a single route: ```go k.GET("/admin", adminHandler, authMiddleware, rateLimitMiddleware) ``` ### Execution Order ``` → Global 1 → Global 2 → Group 1 → Route 1 [Handler] ← Route 1 ← Group 1 ← Global 2 ← Global 1 ``` The first middleware registered is the outermost — it runs first on the way in, and last on the way out. ### Recommended Order Register `Logger` **before** `Recovery`. The reverse order causes `Logger`'s deferred log line to read the status before `Recovery` writes the 500, which means panicked requests log as `200`. ```go k.Use( middleware.Logger(), // outermost — sees the final status middleware.Recovery(), // catches panics from inner handlers middleware.RequestID(), ) ``` ### Writing Custom Middleware ```go func MyMiddleware(h kite.Handler) kite.Handler { return func(ctx *kite.Context) error { // before handler err := h(ctx) // after handler return err } } ``` ## Built-in Middleware All middleware lives in the `middleware` sub-package: ```go import "git.trcreatives.at/trcreatives/go-kite/middleware" ``` ### Logger Logs method, path, status code and latency for every request. Logs from a `defer`, so panicked and errored requests are captured too (provided Logger is registered before Recovery). ```go // simple k.Use(middleware.Logger()) // custom k.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ Output: logFile, Format: func(method, path string, statusCode int, latency time.Duration) string { return fmt.Sprintf("%s %s %d %dms", method, path, statusCode, latency.Milliseconds()) }, Skip: func(ctx *kite.Context) bool { return ctx.GetPath() == "/health" }, })) ``` ### Recovery Catches panics and returns a `500` instead of crashing the server. ```go // simple k.Use(middleware.Recovery()) // custom — integrate with Sentry, Datadog, etc. k.Use(middleware.RecoveryWithConfig(middleware.RecoveryConfig{ OnPanic: func(ctx *kite.Context, recovered any, stack []byte) error { sentry.CaptureException(fmt.Errorf("%v", recovered)) return ctx.WriteJSON(http.StatusInternalServerError, map[string]string{ "error": "internal server error", }) }, })) ``` ### RequestID Attaches a unique ID to every request. Reuses an incoming `X-Request-ID` header if present, for distributed tracing. ```go // simple k.Use(middleware.RequestID()) // custom header k.Use(middleware.RequestIDWithConfig(middleware.RequestIDConfig{ Header: "X-Trace-ID", })) // retrieve in handler id := ctx.GetValue("X-Request-ID").(string) ``` ### CORS Sets Cross-Origin Resource Sharing headers and handles preflight requests. ```go // allow all origins (no credentials) k.Use(middleware.CORS()) // restrict to specific origins k.Use(middleware.CORS("https://myapp.com", "https://staging.myapp.com")) // full control k.Use(middleware.CORSWithConfig(middleware.CORSConfig{ AllowedOrigins: []string{"https://myapp.com"}, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"}, AllowedHeaders: []string{"Content-Type", "Authorization"}, ExposedHeaders: []string{"X-Request-ID"}, AllowCredentials: true, MaxAge: 86400, })) ``` For multi-origin allowlists, the middleware checks the request's `Origin` against the list and echoes back only the matching one — `Access-Control-Allow-Origin` always contains a single origin, never a comma-separated list. A `Vary: Origin` header is added so caches behave correctly. > **Note:** When `AllowCredentials: true` is combined with `AllowedOrigins: ["*"]`, the middleware echoes the request's `Origin` instead of sending `*`, since browsers reject that combination. ### MaxBodySize Protects against oversized request bodies. Returns `413` if the limit is exceeded. ```go // global 4MB limit k.Use(middleware.MaxBodySize(4 << 20)) // per-route override for file uploads k.POST("/upload", uploadHandler, middleware.MaxBodySize(100 << 20)) ``` ## Context Every handler receives a `*kite.Context` with the following methods: ### Request | Method | Description | | ---------------------------------------------- | ------------------------------------- | | `GetRequest() *http.Request` | Underlying request | | `GetResponse() http.ResponseWriter` | Underlying response writer | | `GetContext() context.Context` | Underlying context | | `GetMethod() string` | HTTP method | | `GetPath() string` | Request path | | `IsMethod(method string) bool` | Check HTTP method | | `GetPathParam(key string) string` | Route parameter (e.g. `/users/:id`) | | `GetQueryParam(key string) string` | Query string parameter | | `GetHeader(key string) string` | Request header | | `GetIP() string` | Client IP, respects `X-Forwarded-For` | | `GetCookie(name string) (*http.Cookie, error)` | Request cookie | | `GetStatusCode() int` | Current response status code | ### Values | Method | Description | | ----------------------------- | ---------------------------- | | `SetValue(key string, v any)` | Store a value in the context | | `GetValue(key string) any` | Retrieve a stored value | ### Forms | Method | Description | | --------------------------------------------------------------------------------- | ----------------- | | `GetForm() (url.Values, error)` | All form values | | `GetFormValue(key string) string` | Single form value | | `GetMultipartForm(maxMemory int64) (*multipart.Form, error)` | Multipart form | | `GetMultipartFormFile(key string) (multipart.File, *multipart.FileHeader, error)` | Uploaded file | ### Body Binding | Method | Description | | ----------------------- | ------------------------ | | `BindJSON(v any) error` | Decode JSON request body | | `BindXML(v any) error` | Decode XML request body | ### Response | Method | Description | | ----------------------------------------- | ---------------------------------------- | | `WriteBytes(status int, v []byte) error` | Raw bytes response | | `WriteString(status int, v string) error` | Plain text response (UTF-8) | | `WriteJSON(status int, v any) error` | JSON response (UTF-8) | | `WriteXML(status int, v any) error` | XML response (UTF-8) | | `WriteNoContent() error` | 204 No Content | | `Redirect(status int, url string) error` | Redirect | | `SetHeader(key, value string)` | Set response header (replaces existing) | | `AddHeader(key, value string)` | Append response header (for multi-value) | | `SetCookie(cookie *http.Cookie)` | Set response cookie | ## Error Handling Handlers return `error`. By default, errors are logged and a `500` is returned to the client. ```go k.GET("/users/:id", func(ctx *kite.Context) error { user, err := db.GetUser(ctx.GetPathParam("id")) if err != nil { return err // caught by error handler } return ctx.WriteJSON(http.StatusOK, user) }) ``` ### Custom Error Handler ```go k.SetErrorHandler(func(ctx *kite.Context, err error) error { return ctx.WriteJSON(http.StatusInternalServerError, map[string]string{ "error": err.Error(), }) }) ``` ### Custom Not Found Handler ```go k.SetNotFoundHandler(func(ctx *kite.Context) error { return ctx.WriteJSON(http.StatusNotFound, map[string]string{ "error": "route not found", }) }) ``` ### Custom Method Not Allowed Handler ```go k.SetMethodNotAllowedHandler(func(ctx *kite.Context) error { return ctx.WriteJSON(http.StatusMethodNotAllowed, map[string]string{ "error": "method not allowed", }) }) ``` ## Server Configuration ```go k := kite.New() k.SetServerReadTimeout(10 * time.Second) // default: 10s k.SetServerWriteTimeout(30 * time.Second) // default: 30s k.SetServerIdleTimeout(60 * time.Second) // default: 60s k.SetServerShutdownTimeout(30 * time.Second) // default: 30s ``` ## Graceful Shutdown `Start()` blocks until `SIGINT` or `SIGTERM` is received, then waits for all in-flight requests to finish before exiting. This maps directly to how Docker, Kubernetes, and systemd stop processes. ```go if err := k.Start("localhost:8080"); err != nil { log.Fatal(err) } ``` ## TLS Use `StartTLS` instead of `Start` and provide your certificate and key files: ```go if err := k.StartTLS("localhost:443", "cert.pem", "key.pem"); err != nil { log.Fatal(err) } ``` For local development you can generate a self-signed certificate: ```bash openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes ``` For production, use a certificate from a trusted CA. [Caddy](https://caddyserver.com) and [Nginx](https://nginx.org) are both good options as a reverse proxy if you prefer to terminate TLS outside the application. ## Performance Benchmarked on a MacBook Pro. Each scenario ran for 30 seconds per concurrency level with a 5 second warmup. ### Hello World (string + logger middleware) | Concurrency | RPS | Avg Latency | Avg CPU | Avg RAM | | ----------- | ----------- | ----------- | --------- | ----------- | | 1 | 22,076 | 0.04ms | 31.0% | 20.95MB | | 10 | 79,567 | 0.12ms | 86.2% | 24.01MB | | 25 | 95,066 | 0.25ms | 88.8% | 24.54MB | | 50 | 100,283 | 0.47ms | 91.5% | 27.81MB | | 100 | 113,956 | 0.83ms | 93.5% | 30.91MB | | 250 | 128,858 | 1.81ms | 97.0% | 43.42MB | | **500** | **132,760** | **3.45ms** | **91.9%** | **58.20MB** | | 1000 | 131,101 | 6.92ms | 98.6% | 83.23MB | ``` RPS 140k │ ▓▓▓▓ ████ 120k │ ▓▓▓▓ ████ ████ 100k │ ████ ████ ████ ████ ████ ████ 80k │ ████ ████ ████ ████ ████ ████ ████ 60k │ ████ ████ ████ ████ ████ ████ ████ 40k │ ████ ████ ████ ████ ████ ████ ████ ████ 20k │ ████ ████ ████ ████ ████ ████ ████ ████ └──────────────────────────────────────────────▶ 1 10 25 50 100 250 500 1000 Concurrency ``` > **Sweet spot: 250–500 workers** — RPS plateaus around 132k while latency stays under 4ms. --- ### SQLite Counter (UPDATE + SELECT + JSON response) | Concurrency | RPS | Avg Latency | Avg CPU | Avg RAM | | ----------- | ----- | ----------- | ------- | -------- | | 1 | 4,983 | 0.20ms | 19.9% | 83.34MB | | 10 | 4,728 | 2.04ms | 19.4% | 86.30MB | | 25 | 4,433 | 5.50ms | 19.0% | 88.81MB | | 50 | 2,913 | 15.98ms | 29.5% | 61.68MB | | 100 | 2,139 | 38.61ms | 20.8% | 71.38MB | | 250 | 628 | 189.88ms | 18.0% | 102.66MB | | 500 | 257 | 548.91ms | 11.9% | 164.40MB | | 1000 | 130 | 853.20ms | 9.4% | 288.71MB | ``` RPS 5.0k │ ████ ████ 4.0k │ ████ ████ ████ 3.0k │ ████ ████ ████ ████ 2.0k │ ████ ████ ████ ████ ████ 1.0k │ ████ ████ ████ ████ ████ ████ 500 │ ████ ████ ████ ████ ████ ████ ████ 100 │ ████ ████ ████ ████ ████ ████ ████ ████ └──────────────────────────────────────────────▶ 1 10 25 50 100 250 500 1000 Concurrency ``` > **Sweet spot: 1 worker** — SQLite's single-writer model means concurrency hurts. RPS drops sharply beyond 10 workers as write contention builds up. This is a SQLite limitation, not a kite limitation — a Postgres or MySQL backend would scale horizontally. --- ### Key Takeaways - **Pure HTTP throughput** peaks at ~132k RPS with negligible RAM usage (~58MB at peak) - **Middleware overhead** (logger) adds virtually no cost - **RAM scales linearly** with concurrency — roughly 50–85 bytes per idle goroutine - **CPU saturates around 250 workers** for in-memory workloads — beyond that, latency grows but RPS flattens - **Database workloads** are bottlenecked by the DB, not the framework — kite's overhead is not the limiting factor ## License MIT