Getting started
Edda is an effect-based web framework for Saga. It sits on top of
saga_http and gives you a
router, request/response wrappers, and a few conventions for installing
effects on your routes.
Status: early but usable. The router, app root, request/response helpers, forms, cookies, static files, streaming, CORS, JSON, and WebSocket APIs are the first stable slice.
Edda.Specis the optional route-contract layer for JSON APIs that want OpenAPI generation.
What it gives you
- A router (
route,choose,group,mount) with capturing path parameters (/users/:id). - An app root (
create_app,use_routes,start,wait,serve) that wrapssaga_httpwhile still letting your app decide where effects are discharged. - Lean request/response helpers: headers, cookies, forms, multipart uploads,
static files, streaming, content negotiation, redirects, and
Vary. - Three composable middleware shapes — wrap functions, opt-in effect
handlers, and ambient context handlers — all of which fall out of
ordinary Saga features rather than a
Middlewaretype. - A small
Edda.Jsonmodule that bridges tosaga_jsonfor typed encoding and decoding. - An optional
Edda.Speclayer for JSON endpoint contracts and OpenAPI output.
It is not opinionated about databases, ORMs, templating, logging formats, or app structure.
How it works
The thing that makes Edda interesting isn't the router. It's that almost everything you'd recognize as "framework features" in Express or Phoenix or Rails falls out of Saga's effect system rather than being invented by the framework.
A handler is a function Request -> Response needs {..e}. The ..e
is whatever effects the handler needs. The router widens those rows
across a list of routes; the boundary discharges them by installing
handlers. That's almost the whole story.
Here is the same story told three ways — feature, language primitive, what Edda has to add:
| You want | Saga gives you | Edda adds |
|---|---|---|
| Middleware | Effect handlers around a computation | Nothing — there's no Middleware type. Three patterns (wraps, opt-in, ambient) fall out of how you install handlers. |
| Request-scoped values (current user, request ID, JWT claims) | A handler installed per request that closes over the request | A convention: install the handler in your app/router boundary before passing it to use_routes. |
| Typed errors that map to HTTP statuses | A Fail effect plus an exhaustive handler at the boundary | Nothing. Use the fail effect with an abort-early handler and let errors bubble up. The compiler ensures every variant gets mapped. |
| Scoped resources (open in route, auto-close at end) | The Scope effect + acquire_scoped! acquire release pattern from the stdlib | Nothing. Install run_scoped once at the boundary; routes acquire whatever they want. |
| Resource cleanup that survives errors and panics | A handler arm with a finally { ... } block — guaranteed to run on normal completion, abort, or panic | Nothing. A scoped DB-pool handler with Just Works inside a route, with no framework support. |
| Background work per request (fire-and-forget, supervised children) | beam_actor + Saga's process/supervision effects | Nothing. The route spawns; supervision lives in the same handler stack. |
| Capability-based access control | The needs {..} row on each route, checked at compile time | A router that lets routes with different rows live in one choose list. |
The flexibility and compositional powers of effects mean the framework needs almost nothing on its own -- the surface area you have to learn is mostly the language, not the framework. New things you build using effects compose with everything else without ceremony.
A more concrete example. Suppose you want every route in a sub-app to get a database connection that's released when the route ends, including if the route panics mid-handler. Routes that need the connection declare it as an effect; the boundary opens one connection per request and serves it up.
effect Db {
fun db : Unit -> Connection
}
fun list_items : Request -> Response needs {Db}
list_items _ = {
let conn = db! ()
...
}
fun sub_app : Request -> Response
sub_app req = {
let conn = acquire_scoped! open_db close_db
choose [
route GET "/items" list_items,
route GET "/items/:id" show_item,
] req
} with {
run_scoped,
db () = resume conn
}Four moving parts, each from a different place:
acquire_scoped!comes fromStd.Scope. It opens the connection and registersclose_dbto run when the scope ends.run_scopedcomes from the stdlib too. Itsfinallyblock firesclose_dbno matter how the route exits — normal return,fail!, panic, anything.effect Dband thewith { db () = resume conn }handler are ordinary Saga. The route asks for a connection viadb! (); the handler supplies the one we just opened. No need to thread the connection manually through arguments.chooseis the only Edda piece. The type system makes sure routes can't skip the cleanup, because every effect they declare has to be discharged somewhere up the stack.
The result is a framework whose source code is small, whose surface area is small, but powerful.
Install
Add Edda to your project.toml:
[deps]
edda = { git = "https://github.com/dylantf/edda" }
saga_http = { git = "https://github.com/dylantf/saga_http" }
saga_json = { git = "https://github.com/dylantf/saga_json" }saga_http is the underlying server; saga_json is only needed if
you use Edda.Json. During local framework development these may point at
workspace paths instead of Git URLs.
Hello world
module Main
import Std.IO (console, println)
import Edda (Request, choose, route, GET, create_app, use_routes, use_port, start, wait)
import SagaHttp.Http (text, Response)
fun home : Request -> Response
home _ = text 200 "hello from edda"
fun router : Request -> Response
router req = choose [
route GET "/" home,
] req
main () = {
let port = 8080
let app =
create_app
|> use_routes router
|> use_port port
case start app {
Err e -> dbg ("startup failed", e)
Ok running -> {
println $"Server running at http://localhost:{port}"
wait running
}
}
} with consoleThree things worth noticing:
router : Request -> Response— this small router is pure. Apps that use decoded form/query/multipart helpers may needRequestParserConfig; the app root supplies that for you. Any other app effects should be handled in the function you pass touse_routes.create_app |> use_routes router |> use_port port— builds the app root with defaults, then overrides the pieces this program cares about.start app— starts the server and returns after the socket is bound. TheOkbranch is the natural place for startup logs or metrics.wait running— keeps the program alive until the server shuts down.serve app— starts the server, installs Edda's default parser policy, provides Saga's BEAM actor handler, and blocks until shutdown. Server events are discarded only if no handler was configured withuse_server_events.choose [...]— the router. Each route is a function;choosetries them top to bottom.
Demos
Run the example app with:
saga runThe demo server listens on http://localhost:8080.
/formrenders and submits anapplication/x-www-form-urlencodedform. The POST route decodes aFormValuesvalue and preserves duplicate field names./form/multipartrenders and submits a buffered multipart form with text and file values./sessiondemonstrates a tiny cookie-backed login/logout flow./session/secretis a protected route that redirects back to/sessionunless the demo session cookie is present./static/cats/cat1.jpgis served by the static-directory helper./streamdemonstrates chunked response streaming./ws/echodemonstrates WebSocket routing./json/*demonstrates JSON request/response helpers and CORS./errors/*demonstrates typedFailand panic recovery.
What's next
- Routing — the full router API.
- Middleware — the three shapes and when to reach for which.
- Error handling — typed
Failfor early exit, plususe_panic_recoveryfor the unexpected. - JSON —
Edda.Jsonfor typed request and response bodies. - Forms, cookies, and static files — body helpers, streaming uploads, signed cookies, and serving files.
- Runtime model — one BEAM process per request, what falls out of it, and the supervision / cleanup patterns it enables.