Files
go-kite/README.md
Timo Riegebauer ae5d1f610a feat: middleware reach, response writer interfaces, and CORS fixes
- Run global middleware for all requests, including OPTIONS preflights, NotFound, and MethodNotAllowed — previously bypassed by httprouter's internal handling
- Implement Hijacker, Flusher, and Pusher on the response writer for WebSocket, SSE, and HTTP/2 push support
- Fix CORS: echo a single matching origin, handle AllowCredentials with wildcard, append Vary: Origin
- Logger logs from a defer to capture correct status on panicked requests
- Static and StaticFS accept route middleware; add Context.AddHeader; warn on NotFound handler override
2026-05-17 17:51:56 +02:00

525 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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: 250500 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 5085 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