- 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
525 lines
18 KiB
Markdown
525 lines
18 KiB
Markdown
# 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
|