Heimdall, the server
A blocking accept loop on core:net. It parses the
request line, bounds the read buffer, and hands a fresh Bifrost to the
rune chain. server.odin
A from-scratch web framework in Odin
Gjallarhorn is the horn Heimdall sounds at the gates of Ásgarð. Here it is a small, honest web framework — an HTTP server, a router, an onion of middleware, and an ORM that speaks PostgreSQL on a hand-rolled wire protocol. No libpq, no dependencies. Just structs, runes, and the well of memory.
Every request crosses a Bifrost — the rainbow bridge, and the object that carries a request in and a response back out. It threads through a chain of Runes (middleware), lands on a Handler, and may draw from Mímir's well to remember or recall rows. The whole framework is one Odin package split across a handful of files, each keeping its registration verb beside its logic.
A blocking accept loop on core:net. It parses the
request line, bounds the read buffer, and hands a fresh Bifrost to the
rune chain. server.odin
The request/response object. Path params, response headers, and the
text / json helpers a handler calls. Named so
it never shadows Odin's context. bifrost.odin
Each rune wraps the rest of the pipeline, onion-style. With no
closures in Odin, the remaining chain is threaded through the Bifrost
itself. middleware.odin
The ORM. Your db:-tagged structs are a shape; Mímir
remembers them as rows and migrates them at startup.
mimir.odin · postgres.odin
The templating engine. A template is the warp strung on the loom;
weave runs your data through it as the weft to produce
HTML. A Jinja dialect, HTML-escaped by default. loom.odin
The accept loop reads up to MAX_REQUEST bytes, parses
METHOD SP TARGET SP HTTP/1.1, strips
the query string, and builds a Bifrost.
next(&b) advances an index through the registered
middleware — logger, then cors — each free
to act before and after the call downstream.
Method first, then a segment-wise path match. :name
segments capture into params. No route? Static mounts are
tried for GET, else a 404.
Your procedure reads params, perhaps draws a well, and
writes back with text or json. The response
crosses the Bifrost and the socket closes.
Handlers are plain procedures of type proc(b: ^Bifrost) —
Odin has no methods or closures, so the app is always passed by pointer.
Register routes with the method verbs; literal paths should be declared
before :param patterns so a capture never swallows a fixed
segment.
// Literal routes before the :id pattern, else ":id" captures "schema". gh.get(app, "/sample/schema", schema_handler) gh.get(app, "/sample/:id", get_handler) // CRUD against Postgres via Mímir. Method is matched before path. gh.post(app, "/sample/:name", create_handler) // create gh.put(app, "/sample/:id/:name", update_handler) // update gh.delete(app, "/sample/:id", delete_handler) // delete
Inside a handler, params come back idiomatically as
(value, ok):
get_handler :: proc(b: ^gh.Bifrost) {
id, ok := gh.param_int(b, "id")
if !ok {
gh.text(b, 400, "id must be an integer")
return
}
gh.json(b, 200, Sample{id = id, name = "thing"})
}
A rune is a proc(b: ^Bifrost, next: Next). Inscribe one with
rune; they run in registration order, each wrapping
everything after it. Calling next(b) runs the next layer —
or, at the end, dispatches the route. Skip the call to short-circuit, the
way cors answers a preflight OPTIONS directly.
gh.rune(&app, gh.logger) // → GET /sample/7 gh.rune(&app, gh.cors) // CORS headers + preflight short-circuit cors :: proc(b: ^gh.Bifrost, next: gh.Next) { gh.set_header(b, "Access-Control-Allow-Origin", "*") if b.method == .Options { gh.text(b, 204, "") // do not call next return } next(b) // run the rest of the onion }
Mímir guards the well beneath Yggdrasil whose water is memory. Here your
structs describe a shape and Mímir remembers it as rows. db:
struct tags drive everything; you never write a CREATE TABLE.
The well speaks a dialect (Postgres, MySQL, or SQLite) and — crucially —
values never reach the SQL string. Every value is a bound
parameter. That is the injection checkpoint, held end to end through the
hand-rolled Postgres wire client.
// The "M" in MVC. Tags: column name, then flags. Sample :: struct { id: int `db:"id,pk,auto"`, // auto primary key name: string `db:"name,notnull"`, // required text } // Hand Mímir the model once; migrate() carves its table at run(). gh.remember(app, Sample)
Building procs need only the dialect; exec / query
reach through the app's live connection. A handler draws its well straight
from the Bifrost:
create_handler :: proc(b: ^gh.Bifrost) {
name, _ := gh.param(b, "name")
w := gh.well(b)
rows, ok := gh.query(w, gh.offer(w, Sample{name = name}))
if !ok {
gh.text(b, 503, "database unavailable")
return
}
id, _ := strconv.parse_int(rows.rows[0][0]) // the RETURNING id
gh.json(b, 201, Sample{id = id, name = name})
}
dbname empty and
Mímir stays out of the water — migrations print their DDL instead of
executing, so local development needs no database at all. Set a
dbname to go live: run() connects, runs the
startup/auth handshake (trust, cleartext, MD5, or SCRAM-SHA-256, with
optional TLS), and auto-migrates every remembered model into real tables.
hail mounts a directory under a URL prefix for GET requests.
Explicit routes win; mounts are tried only when nothing matches. The
security checkpoint here is path traversal — a request path is joined,
cleaned, and must stay inside the mount root, or it's a 403. This very
page is served that way.
// Serve ./public at /static — this page lives here. gh.hail(&app, "/static", "./public")
At the well of Urðr the Norns weave the threads of fate; so here a
template is the warp already strung on the loom, and
weave runs the weft of your data through it
to produce the finished cloth — HTML. The dialect is Jinja's, pared to its
load-bearing parts: output an expression, pipe it through filters, branch
with if, and iterate with for.
<!-- A template: the warp strung on the loom --> <h1>{{ title | upper }}</h1> {% if user %}<p>Welcome, {{ user.name }}.</p>{% else %}<p>Hail, stranger.</p>{% endif %} <ul> {% for n in norns %} <li>{{ loop.index }}. {{ n | capitalize }}</li> {% else %} <li>the threads are cut</li> {% endfor %}</ul>
Inside a for, a loop binding carries
index / index0 / first /
last / length, as in Jinja. You thread the
context with warp (named values) and list
(iterables), then weave:
ctx := gh.warp(
{"title", "Gjallarhorn"},
{"user", gh.warp({"name", "Heimdallr"})},
{"norns", gh.list("urd", "verdandi", "skuld")},
)
html, _ := gh.weave(src, ctx) // string in, woven HTML out
From a handler, render loads a template file, weaves a context
through it, and sends it as HTML. Or mount a whole directory with the
four-arg hail: a Provider builds the context fresh
per request, so it can read the path, params, or the well.
// One file, one context, sent as text/html. gh.render(b, "./templates/hello.html", ctx) // Mount ./templates at /pages; GET /pages/x.html weaves templates/x.html. gh.hail(&app, "/pages", "./templates", provider)
{{ … }} is
HTML-escaped unless its filter pipeline ends in | safe (or
| escape, which escapes then marks safe). The decision rides
alongside each value as it's evaluated, so it's made per output, never
globally. Template path is supplied by the handler, not the request — the
user-path traversal checkpoint lives over in hail's static
mounts.
Construct the app, inscribe your runes, hail your static dir, register your routes, and sound the horn. Defining a tagged struct and remembering it is the entire schema step — the table follows from the shape.
main :: proc() {
app := gh.new(gh.Config{
port = 8091,
db_type = .Postgres,
postgres = gh.Postgres_Config{
host = "127.0.0.1", port = 5432,
user = "app", password = "secret", dbname = "gjallarhorn",
},
})
gh.rune(&app, gh.logger) // onion order
gh.rune(&app, gh.cors)
gh.hail(&app, "/static", "./public") // serve files
sample.register(&app) // remember models + routes
gh.run(&app) // connect, migrate, listen
}
| GET | /sample/schema | inspect the DDL & SQL Mímir generates |
| GET | /sample/:id | read a row |
| POST | /sample/:name | create — returns the new id |
| PUT | /sample/:id/:name | rename by id |
| DELETE | /sample/:id | forget by id |
Data rides in the path — the server has no request-body parser yet. Honest about its phase, like the rest of the codebase.