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
This commit is contained in:
2026-05-17 17:51:56 +02:00
parent 36c6d76e53
commit ae5d1f610a
7 changed files with 237 additions and 69 deletions

103
README.md
View File

@@ -9,10 +9,12 @@ A fast, lightweight, and expressive HTTP framework for Go, built on top of [http
- **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 handlers, not-found handlers all configurable
- **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
@@ -37,8 +39,8 @@ func main() {
k := kite.New()
k.Use(
middleware.Logger(), // first, so it observes the final status after Recovery
middleware.Recovery(),
middleware.Logger(),
middleware.RequestID(),
)
@@ -66,7 +68,7 @@ k.PUT("/users/:id", updateUser)
k.DELETE("/users/:id", deleteUser)
```
All standard HTTP methods are supported: `GET`, `POST`, `PUT`, `DELETE`, `HEAD`, `OPTIONS`, `CONNECT`, `TRACE`, `PATCH`.
Supported HTTP methods: `GET`, `POST`, `PUT`, `DELETE`, `HEAD`, `OPTIONS`, `CONNECT`, `TRACE`.
## Route Groups
@@ -94,6 +96,46 @@ 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.
@@ -104,11 +146,11 @@ type Middleware func(h Handler) Handler
### Global Middleware
Applied to every route:
Applied to every request, including NotFound, MethodNotAllowed, and OPTIONS preflights:
```go
k.Use(middleware.Recovery())
k.Use(middleware.Logger())
k.Use(middleware.Recovery())
```
### Route Middleware
@@ -133,6 +175,20 @@ k.GET("/admin", adminHandler, authMiddleware, rateLimitMiddleware)
← 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
@@ -156,7 +212,7 @@ import "git.trcreatives.at/trcreatives/go-kite/middleware"
### Logger
Logs method, path, status code and latency for every request.
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
@@ -179,7 +235,7 @@ k.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
Catches panics and returns a `500` instead of crashing the server.
```go
// simple — always register this first
// simple
k.Use(middleware.Recovery())
// custom — integrate with Sentry, Datadog, etc.
@@ -207,7 +263,7 @@ k.Use(middleware.RequestIDWithConfig(middleware.RequestIDConfig{
}))
// retrieve in handler
id := ctx.GetValue("X-Request-ID")
id := ctx.GetValue("X-Request-ID").(string)
```
### CORS
@@ -215,11 +271,11 @@ id := ctx.GetValue("X-Request-ID")
Sets Cross-Origin Resource Sharing headers and handles preflight requests.
```go
// allow all origins
// allow all origins (no credentials)
k.Use(middleware.CORS())
// restrict origins
k.Use(middleware.CORS("https://myapp.com"))
// restrict to specific origins
k.Use(middleware.CORS("https://myapp.com", "https://staging.myapp.com"))
// full control
k.Use(middleware.CORSWithConfig(middleware.CORSConfig{
@@ -232,7 +288,9 @@ k.Use(middleware.CORSWithConfig(middleware.CORSConfig{
}))
```
> **Note:** `AllowCredentials: true` cannot be combined with `AllowedOrigins: ["*"]` — browsers will reject it.
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
@@ -292,16 +350,17 @@ Every handler receives a `*kite.Context` with the following methods:
### Response
| Method | Description |
| ----------------------------------------- | ------------------- |
| `WriteBytes(status int, v []byte) error` | Raw bytes response |
| `WriteString(status int, v string) error` | Plain text response |
| `WriteJSON(status int, v any) error` | JSON response |
| `WriteXML(status int, v any) error` | XML response |
| `WriteNoContent() error` | 204 No Content |
| `Redirect(status int, url string) error` | Redirect |
| `SetHeader(key, value string)` | Set response header |
| `SetCookie(cookie *http.Cookie)` | Set response cookie |
| 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