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:
103
README.md
103
README.md
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user