A lightweight HTTP/1.1 server built from raw TCP sockets in Go.
- About
- Installation
- Quick Start
- Routing
- Request Object
- Response Helpers
- Configuration
- Static Files
- Custom 404 Page
- TLS/HTTPS
- Testing
- Performance
- Limitations
- License
raw-http is an HTTP/1.1 server implementation that handles:
- Request parsing (method, path, headers, body)
- Route matching with path parameters (
/users/:id) - Query string and body parsing (JSON + form-encoded)
- Static file serving with MIME detection
- Keep-alive connections
- TLS/HTTPS support
- Panic recovery
- Graceful shutdown
This is a learning project. It works for small applications but is not battle-tested. For production, use Go's net/http package.
go get github.com/codetesla51/raw-http@v1.0.1Then import in your code:
import "github.com/codetesla51/raw-http/server"git clone https://github.com/codetesla51/raw-http.git
cd raw-http
go build -o server main.go
./serverServer starts on http://localhost:8080 (auto-increments if port is busy).
Here's a complete working server:
package main
import (
"log"
"github.com/codetesla51/raw-http/server"
)
func main() {
// Create server
srv := server.NewServer(":8080")
// Register routes
srv.Register("GET", "/ping", func(req *server.Request) ([]byte, string) {
return server.CreateResponseBytes("200", "text/plain", "OK", []byte("pong"))
})
srv.Register("GET", "/users/:id", func(req *server.Request) ([]byte, string) {
userID := req.PathParams["id"]
return server.CreateResponseBytes("200", "text/plain", "OK", []byte("User: "+userID))
})
srv.Register("POST", "/api/data", func(req *server.Request) ([]byte, string) {
name := req.Body["name"]
if name == "" {
return server.Serve400("name is required")
}
return server.Serve201("created: " + name)
})
// Start server (blocks until Ctrl+C)
if err := srv.ListenAndServe(); err != nil {
log.Fatal(err)
}
}Test it:
curl http://localhost:8080/ping # pong
curl http://localhost:8080/users/42 # User: 42
curl -X POST -d "name=john" http://localhost:8080/api/datarouter.Register(method, path, handler)Use :param syntax to capture URL segments:
router.Register("GET", "/users/:id", func(req *server.Request) ([]byte, string) {
userID := req.PathParams["id"] // "123" from /users/123
return server.CreateResponseBytes("200", "text/plain", "OK", []byte(userID))
})
router.Register("GET", "/posts/:postId/comments/:commentId", func(req *server.Request) ([]byte, string) {
postID := req.PathParams["postId"]
commentID := req.PathParams["commentId"]
// ...
})router.Register("GET", "/search", func(req *server.Request) ([]byte, string) {
q := req.Query["q"] // /search?q=golang
page := req.Query["page"] // /search?q=golang&page=2
// ...
})Form-encoded and JSON bodies are automatically parsed:
router.Register("POST", "/users", func(req *server.Request) ([]byte, string) {
name := req.Body["name"]
email := req.Body["email"]
// ...
})Handlers receive *server.Request:
| Field | Type | Description |
|---|---|---|
Method |
string |
HTTP method (GET, POST, PUT, DELETE) |
Path |
string |
Request path without query string |
PathParams |
map[string]string |
URL parameters from route (:id) |
Query |
map[string]string |
Query string parameters |
Body |
map[string]string |
Parsed request body |
Headers |
map[string]string |
HTTP headers |
Browser |
string |
Detected browser name |
server.CreateResponseBytes(statusCode, contentType, statusMessage, body)
// Example
return server.CreateResponseBytes("200", "application/json", "OK", []byte(`{"ok":true}`))| Function | Code | Use Case |
|---|---|---|
Serve201(msg) |
201 | Resource created |
Serve204() |
204 | Success, no content |
Serve400(msg) |
400 | Bad request / validation error |
Serve401(msg) |
401 | Authentication required |
Serve403(msg) |
403 | Access denied |
Serve405(method, path) |
405 | Method not allowed |
Serve429(msg) |
429 | Rate limit exceeded |
Serve500(msg) |
500 | Internal server error |
Serve502(msg) |
502 | Bad gateway |
Serve503(msg) |
503 | Service unavailable |
Example:
router.Register("POST", "/login", func(req *server.Request) ([]byte, string) {
if req.Body["password"] == "" {
return server.Serve400("password required")
}
if !authenticate(req.Body["user"], req.Body["password"]) {
return server.Serve401("invalid credentials")
}
return server.Serve201("logged in")
})cfg := &server.Config{
ReadTimeout: 60 * time.Second,
WriteTimeout: 30 * time.Second,
MaxBodySize: 50 * 1024 * 1024, // 50MB
EnableKeepAlive: true,
}
srv := server.NewServerWithConfig(":8080", cfg)
srv.Register("GET", "/ping", handler)
srv.ListenAndServe()cfg := server.DefaultConfig()
cfg.ReadTimeout = 60 * time.Second
router := server.NewRouterWithConfig(cfg)
router.ListenAndServe(":8080")| Option | Type | Default | Description |
|---|---|---|---|
ReadTimeout |
time.Duration |
30s | Max time to read entire request |
WriteTimeout |
time.Duration |
30s | Max time to write response |
IdleTimeout |
time.Duration |
120s | Keep-alive timeout |
MaxHeaderSize |
int |
8192 | Max header size (bytes) |
MaxBodySize |
int64 |
10MB | Max request body size |
EnableKeepAlive |
bool |
true | HTTP/1.1 keep-alive |
EnableLogging |
bool |
false | Log requests to stdout |
Files in pages/ directory are served automatically:
pages/
├── index.html → GET /index.html
├── styles.css → GET /styles.css
├── js/
│ └── app.js → GET /js/app.js
└── 404.html → Custom 404 page
MIME types are detected automatically (.html, .css, .js, .png, .jpg, etc).
Path traversal attacks (/../etc/passwd) are blocked.
Create pages/404.html:
<!DOCTYPE html>
<html>
<head>
<title>404 - Not Found</title>
</head>
<body>
<h1>404</h1>
<p>Page not found.</p>
</body>
</html>This page is returned for any unmatched route. If the file doesn't exist, the server returns plain text "Route Not Found".
Enable HTTPS with a single line:
srv := server.NewServer(":8080")
srv.EnableTLS(":8443", "server.crt", "server.key")
srv.Register("GET", "/ping", handler)
srv.ListenAndServe() // Serves HTTP on 8080 and HTTPS on 8443# Generate self-signed certificate (development only)
openssl req -x509 -newkey rsa:4096 -keyout server.key -out server.crt -days 365 -nodesPlace server.crt and server.key in the project root.
For production, use Let's Encrypt:
certbot certonly --standalone -d yourdomain.com
cp /etc/letsencrypt/live/yourdomain.com/fullchain.pem server.crt
cp /etc/letsencrypt/live/yourdomain.com/privkey.pem server.keyRun tests:
go test ./server/... -v21 tests cover:
- HTTP parsing (request line, headers, body)
- Route matching (exact, pattern, params)
- Response formatting
- Error handling
┌─────────────────────────────────────────────────────────────┐
│ Server Struct │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Router │ │ TLS Config │ │ Graceful Shutdown │ │
│ └──────┬──────┘ └─────────────┘ └─────────────────────┘ │
└─────────┼───────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Connection Handler │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Buffer Pool │ │ Request │ │ Keep-Alive Loop │ │
│ │ (sync.Pool)│ │ Parser │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Three sync.Pool instances reduce garbage collection pressure:
| Pool | Buffer Size | Purpose |
|---|---|---|
chunkBufferPool |
4KB | Reading from TCP connection |
requestBufferPool |
8KB | Accumulating request headers |
responseBufferPool |
Dynamic | Building HTTP responses |
Buffers larger than 16KB are discarded to prevent memory bloat.
// How it works internally
buf := chunkBufferPool.Get().(*[]byte)
defer chunkBufferPool.Put(buf)
n, _ := conn.Read(*buf)The server handles SIGINT and SIGTERM signals:
- Stop accepting new connections
- Wait for active connections to finish (2 second grace period)
- Close all listeners
- Exit cleanly
// Automatic signal handling
srv := server.NewServer(":8080")
srv.ListenAndServe() // Blocks until Ctrl+C
// Or use custom context for programmatic shutdown
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.ListenAndServeContext(ctx)HTTP/1.1 keep-alive is enabled by default:
- Connections are reused for multiple requests
- Idle timeout: 120 seconds (configurable)
- Reduces TCP handshake overhead
- Significantly improves throughput (5k → 11k req/sec)
Zero-allocation parsing where possible:
- Read raw bytes from connection into pooled buffer
- Split headers from body at
\r\n\r\nmarker - Parse request line:
METHOD /path HTTP/1.1 - Parse headers into map (single allocation)
- Parse body based on Content-Type (JSON or form-encoded)
Every connection handler is wrapped with recovery:
defer func() {
if err := recover(); err != nil {
log.Printf("PANIC recovered: %v\n%s", err, debug.Stack())
conn.Write(errorResponse500)
}
}()A panic in one handler won't crash the server.
Static file serving blocks directory traversal attempts:
// These are blocked:
// /../etc/passwd
// /pages/../../../etc/passwd
// /%2e%2e/etc/passwd
if strings.Contains(cleanPath, "..") {
return Serve403()
}Benchmarks on 8-core system:
| Scenario | Concurrency | Requests/sec | Latency |
|---|---|---|---|
| GET /ping | 100 | 5,601 | 17.9ms |
| GET /ping | 500 | 11,042 | 45.3ms |
| POST with body | 100 | 5,773 | 17.3ms |
Run your own:
# Install Apache Bench
sudo apt install apache2-utils
# Benchmark
ab -n 10000 -c 100 -k http://localhost:8080/ping| Limitation | Impact |
|---|---|
| Not production-tested | Use for learning/small projects only |
| Single process | No clustering support |
| No middleware system | Implement yourself if needed |
| No observability | No built-in metrics/tracing |
| ~5k connection ceiling | Performance degrades at high concurrency |
For production applications, use Go's net/http package.
MIT