Hobby¶
A simple HTTP web framework for Pony, powered by Stallion.
Quick Start¶
Create an Application, register routes with .> chaining, and call
serve() to start listening:
use hobby = "hobby"
use stallion = "stallion"
use lori = "lori"
actor Main
new create(env: Env) =>
let auth = lori.TCPListenAuth(env.root)
hobby.Application
.>get("/", HelloHandler)
.>get("/greet/:name", GreetHandler)
.serve(auth, stallion.ServerConfig("localhost", "8080"), env.out)
primitive HelloHandler is hobby.Handler
fun apply(ctx: hobby.Context ref) =>
ctx.respond(stallion.StatusOK, "Hello!")
class val GreetHandler is hobby.Handler
fun apply(ctx: hobby.Context ref) ? =>
ctx.respond(stallion.StatusOK, "Hello, " + ctx.param("name")? + "!")
Routing¶
Routes use a radix tree with two kinds of dynamic segments:
- Named parameters (
:name): match a single path segment./users/:idmatches/users/42but not/users/42/posts. - Wildcard parameters (
*name): match everything from that point forward, must be the last segment./files/*pathmatches/files/css/style.css.
Static routes have priority over parameter routes at the same position.
Trailing slashes are normalized — /users/ and /users match the same route.
Middleware¶
Attach middleware to individual routes via the middleware parameter:
let mw: Array[hobby.Middleware val] val =
recover val [as hobby.Middleware val: AuthMiddleware] end
app.>get("/private", PrivateHandler where middleware = mw)
Middleware has two phases:
before: runs before the handler. Short-circuit a request by callingctx.respond()— the handler is skipped, butafterphases still run.after: runs after the handler in reverse order. Always runs for every middleware whosebeforewas invoked, regardless of how the chain ended.
Route Groups¶
Group related routes under a shared prefix and middleware with RouteGroup:
let auth_mw: Array[hobby.Middleware val] val =
recover val [as hobby.Middleware val: AuthMiddleware] end
let api = hobby.RouteGroup("/api" where middleware = auth_mw)
api.>get("/users", UsersHandler)
api.>get("/users/:id", UserHandler)
app.>group(consume api)
Groups can be nested — inner groups inherit the outer group's prefix and middleware, with outer middleware running first:
let admin = hobby.RouteGroup("/admin" where middleware = admin_mw)
admin.get("/dashboard", DashboardHandler)
api.>group(consume admin)
// Registers /api/admin/dashboard with [auth_mw, admin_mw]
Application Middleware¶
Apply middleware to every route with Application.add_middleware():
let log_mw: Array[hobby.Middleware val] val =
recover val [as hobby.Middleware val: LogMiddleware(env.out)] end
hobby.Application
.>add_middleware(log_mw)
.>get("/", HelloHandler)
.>group(consume api)
.serve(auth, config, env.out)
Application middleware runs before group middleware, which runs before per-route middleware. Can be called multiple times — middleware accumulates in registration order.
Context Data¶
Middleware communicates with handlers through ctx.set() / ctx.get().
The data map stores Any val values. Middleware authors should provide typed
accessor primitives that use match to recover domain types, following the
convention demonstrated in the middleware example.
Streaming Responses¶
Send chunked HTTP responses by calling ctx.start_streaming() and matching
on the result:
primitive StreamHandler is hobby.Handler
fun apply(ctx: hobby.Context ref) ? =>
match ctx.start_streaming(stallion.StatusOK)?
| let sender: hobby.StreamSender tag =>
MyProducer(sender)
| stallion.ChunkedNotSupported =>
ctx.respond(stallion.StatusOK, "Chunked encoding not supported.")
| hobby.BodyNotNeeded => None
end
actor MyProducer
let _sender: hobby.StreamSender tag
new create(sender: hobby.StreamSender tag) =>
_sender = sender
_send()
be _send() =>
_sender.send_chunk("Hello, ")
_sender.send_chunk("streaming world!")
_sender.finish()
start_streaming() is partial — it errors if a response has already been
sent. It returns (StreamSender tag | ChunkedNotSupported | BodyNotNeeded)
so handlers can fall back to a non-streaming response when the client doesn't
support chunked encoding (e.g., HTTP/1.0), or skip streaming entirely for
HEAD requests (BodyNotNeeded). Existing handlers that don't match on
BodyNotNeeded work correctly — in a statement-position match, unmatched
cases silently fall through. If the handler errors after a successful
start_streaming(), the framework automatically terminates the chunked
response to prevent a hung connection.
Static File Serving¶
Serve files from a directory using the built-in ServeFiles handler. Small
files are served with Content-Length; large files use chunked streaming.
HEAD requests are optimized — ServeFiles responds with Content-Type and
Content-Length headers without reading the file, regardless of file size.
Path traversal is prevented by Pony's FilePath capability system.
use "files"
use hobby = "hobby"
use stallion = "stallion"
use lori = "lori"
actor Main
new create(env: Env) =>
let auth = lori.TCPListenAuth(env.root)
let root = FilePath(FileAuth(env.root), "./public")
hobby.Application
.>get("/static/*filepath", hobby.ServeFiles(root))
.serve(auth, stallion.ServerConfig("0.0.0.0", "8080"), env.out)
Routes must use *filepath as the wildcard parameter name. ServeFiles
detects content types from file extensions. When a request resolves to a
directory, ServeFiles automatically serves index.html from that
directory if it exists; otherwise the request returns 404. For HTTP/1.0
clients requesting files above the chunk threshold, it responds with 505
rather than loading the entire file into memory.
The chunk_threshold parameter (in kilobytes) controls the cutoff between
serving a file in one response vs chunked streaming. Default is 1024 (1 MB):
// Stream files at or above 256 KB instead of the default 1 MB
hobby.ServeFiles(root where chunk_threshold = 256)
Custom content types can be added or defaults overridden via ContentTypes:
let types = hobby.ContentTypes
.add("webp", "image/webp")
.add("avif", "image/avif")
hobby.ServeFiles(root where content_types = types)
Imports¶
Users import three packages:
hobby: Application, BodyNotNeeded, ContentTypes, Context, Handler, Middleware, RouteGroup, ServeFiles, StreamSenderstallion: HTTP vocabulary (Status codes, Method, Headers, ServerConfig, ChunkedNotSupported)lori:TCPListenAuth(env.root)for network accessfiles:FilePath,FileAuth(only needed when usingServeFiles)