Luma Web Server: Building Web Applications Made Simple
Luma now includes a built-in web server that lets you build HTTP applications with minimal boilerplate. True to Luma’s philosophy — handlers are just functions, types are clear and explicit, and you can go from zero to a running server in a few lines of code.
Hello World
fn hello(req: Request) -> Response {
return text("Hello World!")
}
routes: [Route] = [get("/", hello)]
config: ServerConfig = ServerConfig {
port: 3000,
routes: routes,
}
server.start(config)Run it:
luma run server.luma
# Luma server listening on :3000curl http://localhost:3000/
# Hello World!That’s it. No imports, no middleware setup, no boilerplate. Define a handler, create routes, start the server.
Core Concepts
Handlers
A handler is any function that takes a Request and returns a Response:
fn my_handler(req: Request) -> Response {
return text("OK")
}There’s no special interface to implement, no trait to satisfy. It’s just a function.
Routes
Routes bind HTTP methods and URL paths to handlers using helper functions:
get("/", index_handler)
post("/users", create_user)
put("/users/:id", update_user)
delete("/users/:id", delete_user)Each returns a Route value that you collect into a list:
routes: [Route] = [
get("/", index_handler),
post("/users", create_user),
put("/users/:id", update_user),
delete("/users/:id", delete_user),
]Server Configuration
The ServerConfig struct ties everything together:
config: ServerConfig = ServerConfig {
port: 8080,
routes: routes,
}
server.start(config)server.start(config) blocks and listens for incoming HTTP requests. It prints a startup message and runs until the process is stopped.
Request
The Request struct gives you access to everything about the incoming HTTP request:
struct Request {
method: str // "GET", "POST", etc.
path: str // "/users/42"
url: str // "/users/42?sort=name"
headers: {str: str} // request headers
query: {str: str} // query parameters
params: {str: str} // path parameters from :param syntax
body_bytes: [byte] // raw request body
}Accessing Request Data
Headers, query parameters, and path parameters are all maps — use standard map indexing:
fn my_handler(req: Request) -> Response {
// Path parameter
id: str = req.params["id"]
// Query parameter: /search?q=luma
search: str = req.query["q"]
// Request header
content_type: str = req.headers["Content-Type"]
// HTTP method
method: str = req.method
return text("OK")
}Response
The Response struct represents what you send back to the client:
struct Response {
status: int // HTTP status code
headers: {str: str} // response headers
body: [byte] // response body bytes
}Response Helpers
Instead of building Response structs manually, use the built-in helpers:
text(body) — Plain Text
fn hello(req: Request) -> Response {
return text("Hello World!")
}
// Content-Type: text/plain; charset=utf-8html(body) — HTML
fn page(req: Request) -> Response {
return html("<h1>Welcome to Luma!</h1>")
}
// Content-Type: text/html; charset=utf-8Works naturally with string interpolation:
fn greet(req: Request) -> Response {
name: str = req.params["name"]
return html("<h1>Hello, ${name}!</h1>")
}json(body) — JSON
fn status(req: Request) -> Response {
return json({"status": "ok", "version": "1.0"})
}
// Content-Type: application/json
// {"status":"ok","version":"1.0"}redirect(location) — Redirect (HTTP 302)
fn old_page(req: Request) -> Response {
return redirect("/new-page")
}file_download(path) — File Download
fn download(req: Request) -> Response {
return file_download("reports/annual.pdf")
}
// Sets Content-Disposition: attachment; filename="annual.pdf"Custom Responses
For full control, construct Response directly:
fn not_found(req: Request) -> Response {
return Response {
status: 404,
body: "Page not found".to_bytes(),
}
}Dynamic Routes
Path parameters use colon syntax. Parameters are extracted automatically and available in req.params:
fn show_user(req: Request) -> Response {
id: str = req.params["id"]
return text("User ID: ${id}")
}
fn show_post(req: Request) -> Response {
user: str = req.params["user"]
post_id: str = req.params["id"]
return text("User ${user}, Post ${post_id}")
}
routes: [Route] = [
get("/users/:id", show_user),
get("/users/:user/posts/:id", show_post),
]curl http://localhost:8080/users/42
# User ID: 42
curl http://localhost:8080/users/alice/posts/7
# User alice, Post 7Middleware
Middleware wraps handlers to add pre/post-processing. A middleware function receives the request and the next handler in the chain:
fn logger(req: Request, next: (Request) -> Response) -> Response {
print("${req.method} ${req.path}")
resp: Response = next(req)
return resp
}
config: ServerConfig = ServerConfig {
port: 3000,
routes: routes,
middleware: [logger],
}Middleware is applied in order — the first middleware in the list is the outermost wrapper, executed first on the way in and last on the way out.
Error Handling
Set an error handler to catch panics in handlers and return a clean error response:
fn error_handler(err: Error, req: Request) -> Response {
return json({"error": "Internal server error"})
}
config: ServerConfig = ServerConfig {
port: 3000,
routes: routes,
on_error: error_handler,
}If no on_error handler is set, panics will propagate normally.
Complete Example: A Simple API
fn index(req: Request) -> Response {
return html("<h1>Welcome to Luma!</h1>")
}
fn greet(req: Request) -> Response {
name: str = req.params["name"]
return html("<h1>Hello, ${name}!</h1>")
}
fn api_status(req: Request) -> Response {
return json({"status": "ok", "version": "1.0"})
}
fn create_user(req: Request) -> Response {
return json({"status": "created"})
}
routes: [Route] = [
get("/", index),
get("/greet/:name", greet),
get("/api/status", api_status),
post("/api/users", create_user),
delete("/api/users", create_user),
]
config: ServerConfig = ServerConfig {
port: 8080,
routes: routes,
}
server.start(config)luma run api.luma
# Luma server listening on :8080
curl http://localhost:8080/
# <h1>Welcome to Luma!</h1>
curl http://localhost:8080/greet/World
# <h1>Hello, World!</h1>
curl http://localhost:8080/api/status
# {"status":"ok","version":"1.0"}
curl -X POST http://localhost:8080/api/users
# {"status":"created"}Types Reference
Request
| Field | Type | Description |
|---|---|---|
method |
str |
HTTP method (GET, POST, etc.) |
path |
str |
URL path |
url |
str |
Full URL string |
headers |
{str: str} |
Request headers |
query |
{str: str} |
Query string parameters |
params |
{str: str} |
Path parameters from :param routes |
body_bytes |
[byte] |
Raw request body |
Response
| Field | Type | Description |
|---|---|---|
status |
int |
HTTP status code |
headers |
{str: str} |
Response headers |
body |
[byte] |
Response body bytes |
Route Helpers
| Function | Description |
|---|---|
get(path, handler) |
Create a GET route |
post(path, handler) |
Create a POST route |
put(path, handler) |
Create a PUT route |
delete(path, handler) |
Create a DELETE route |
Response Helpers
| Function | Description |
|---|---|
text(body) |
Plain text response (200) |
html(body) |
HTML response (200) |
json(body) |
JSON response (200) |
redirect(location) |
Redirect response (302) |
file_download(path) |
File download response |
ServerConfig
| Field | Type | Description |
|---|---|---|
port |
int |
Port to listen on |
routes |
[Route] |
List of route definitions |
middleware |
[Middleware] |
Middleware chain (optional) |
on_error |
?ErrorHandler |
Error handler (optional) |
Design Philosophy
The web server was designed around three principles:
-
Minimal boilerplate — A hello world server is 12 lines. No imports, no class hierarchies, no configuration files.
-
Handlers are just functions — No interfaces to implement, no decorators, no magic. A handler takes a Request and returns a Response. That’s it.
-
Clear and explicit types — Request, Response, Route, and ServerConfig are plain structs. You can inspect them, construct them, and pass them around like any other value.
Under the hood, Luma compiles to Go and uses Go’s net/http package. You get the performance and reliability of Go’s battle-tested HTTP stack with the simplicity of Luma’s syntax.