Heimdall, the server
An HTTP/1.1 server on core:net with a bounded worker
pool, keep-alive, chunked bodies, and graceful shutdown. Request-smuggling
and CRLF defenses baked in. 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/1.1 server, a router, an onion of
middleware, signed-cookie sessions with CSRF and route guards, file
uploads, an ORM that speaks PostgreSQL on a hand-rolled wire protocol, and
a Jinja-style template engine. No libpq, no dependencies — and a
nest-style CLI to scaffold it all. 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.
An HTTP/1.1 server on core:net with a bounded worker
pool, keep-alive, chunked bodies, and graceful shutdown. Request-smuggling
and CRLF defenses baked in. server.odin
The request/response object. Path/query params, response headers,
cookies, 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. logger, cors, csrf.
middleware.odin
HMAC-signed cookie sessions with server-enforced expiry, a CSRF
synchronizer token, and Wards — per-route auth guards.
Login/logout out of the box. session.odin ·
auth.odin
The ORM. Your db:-tagged structs are a shape; Mímir
remembers them as rows and migrates them at startup — over a from-scratch
Postgres wire client with SCRAM auth. 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
Scaffolding in the spirit of nest: gjallarhorn
new bootstraps a runnable app (vendoring the framework), and
generate resource writes a full CRUD trio. On the AUR.
cli/
A fixed pool of worker threads accepts on the shared socket, so concurrency is bounded, not unbounded. Each frames the request (Content-Length or chunked), rejecting ambiguous framing before it can smuggle.
next(&b) advances an index through the registered
middleware — logger, cors, csrf
— each free to act before and after the call downstream.
Method first, then a segment-wise path match. :name
segments capture into params. A Ward may
guard the route; HEAD falls back to the GET handler with the body
dropped.
Your procedure reads params/JSON/form, perhaps draws a
well, and writes back with text,
json, or a woven template. On SIGTERM the pool drains
in-flight requests and exits cleanly.
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 (get, post,
put, patch, delete, plus
head/options). Literal paths go 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: create/update take a JSON body; only the id rides in the path. gh.post(app, "/sample", create_handler) // create gh.put(app, "/sample/:id", update_handler) // update gh.delete(app, "/sample/:id", delete_handler) // delete // Ward as an optional 4th arg — the handler runs only if it returns true. gh.get(app, "/account", account_handler, gh.require_login)
Read the body the way that fits: bind_json unmarshals into a
struct, form decodes url-encoded and multipart fields
(so CSRF tokens work under either), and files /
upload hand back uploaded files, byte-for-byte.
create_handler :: proc(b: ^gh.Bifrost) {
payload: Sample
if !gh.bind_json(b, &payload) { return } // wrote 400 already
// ...
}
upload_handler :: proc(b: ^gh.Bifrost) {
title := gh.form(b)["title"] // text field
if f, ok := gh.upload(b, "file"); ok { // uploaded file
gh.json(b, 200, struct{ name: string, bytes: int }{f.filename, len(f.data)})
}
}
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 or
csrf rejects a tokenless POST with a 403.
gh.rune(&app, gh.logger) // one structured, colored line per request gh.rune(&app, gh.cors) // CORS headers + preflight short-circuit gh.rune(&app, gh.csrf) // synchronizer-token CSRF, session-backed gh.rune(&app, gh.rate_limit) // per-client token bucket -> 429 + Retry-After 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 }
rate_limit hands each client a bucket of
rate_limit_burst tokens that refills at
rate_limit_rps per second: bursts pass untouched, and only
sustained excess is refused — with a 429 and a
Retry-After saying when the next token lands. It pairs with
the bounded worker pool: the pool caps how much work runs at once, this
caps how fast any one client may ask for it. Clients are keyed by the peer
address captured at accept; behind a trusted reverse proxy, set
rate_limit_trust_forwarded to key on
X-Forwarded-For instead (it's forgeable, so only with a proxy
you trust). Idle buckets are swept, so the table can't grow unbounded.
Custom error pages. on_error swaps the
framework's plain-text errors for your own — the ones Gjallarhorn emits
on your behalf: 404 (no route), 500 (a handler
panicked), 403 (path traversal), the 401 a Ward
falls back to. The 500 handler runs under its own recovery
guard, so even a bug in it degrades to the plain default rather
than crashing the worker.
gh.on_error(&app, 404, proc(b: ^gh.Bifrost) { gh.html(b, 404, "<h1>Lost in Niflheim</h1>") })
Observability comes as two more runes.
request_id tags each request — reusing a sane inbound
X-Request-Id so an upstream proxy's trace carries through,
else minting one — exposes it on b.request_id, echoes it in
the response header, and has the logger print it, so a log line, the
client's response, and the trace all line up. metrics counts
requests by status, in-flight, and cumulative latency, and serves a
Prometheus exposition at /metrics (excluded
from its own counts).
gh.rune(&app, gh.metrics) // outermost — times the chain, serves /metrics gh.rune(&app, gh.request_id) // before logger, so the id reaches log + header gh.rune(&app, gh.logger) // $ curl localhost:8091/metrics // gjallarhorn_requests_total{status="2xx"} 128 // gjallarhorn_requests_in_flight 2
The session is a small string map that rides in a cookie the client holds
— the server keeps no state. An HMAC-SHA256 tag over the
payload (keyed by Config.secret) makes it unforgeable, and the
expiry is signed inside the tag, so a client can't extend its own
session. In a release build, run() refuses to start on an
empty or default secret.
// Log a user in after you verify their credentials. gh.login(b, user_id) // records the id in the signed session uid, ok := gh.current_user(b) // read it back later gh.logout(b) // drop just the auth key // A Ward is proc(b) -> bool. Attach any as a route's 4th arg. require_login :: proc(b: ^gh.Bifrost) -> bool { if _, ok := gh.current_user(b); ok { return true } gh.text(b, 401, "login required") return false }
login deliberately assumes the credentials were already
checked — hash_password / verify_password are
that check: Argon2id (RFC 9106) at OWASP's cost, a fresh
CSPRNG salt per password, constant-time comparison, stored as a standard
PHC string. The salt and cost ride inside the hash, so raising the cost
later never invalidates existing passwords.
// at signup — store this string verbatim stored, ok := gh.hash_password(fields["password"], context.allocator) // "$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>" // at login — same answer for a bad user and a bad password if !gh.verify_password(fields["password"], stored) { gh.text(b, 401, "invalid username or password") return } gh.login(b, user_id)
Mímir guards the well beneath Yggdrasil whose water is memory. In the myth
Óðinn traded an eye for a single draught; here your structs
describe a shape and Mímir remembers it as rows. You never write a
CREATE TABLE, you never write an INSERT, and — the
load-bearing promise — values never reach the SQL string.
Every value is a bound parameter ($1, $2, …)
carried end-to-end through a from-scratch PostgreSQL wire client:
SCRAM-SHA-256 auth, a connection pool, optional TLS, no libpq.
db: tags are the schema
A struct field's db: tag is its column: a name, then
comma-separated flags. Field type decides the SQL type; wrap it in
Maybe(T) to make the column nullable.
Article :: struct {
id: int `db:"id,pk,auto"`, // BIGINT primary key, auto-assigned
slug: string `db:"slug,unique,notnull"`, // UNIQUE NOT NULL TEXT
author: int `db:"author_id,fk:users.id"`, // REFERENCES users(id)
body: string `db:"body"`,
tags: gh.Json `db:"tags"`, // JSONB
posted: time.Time `db:"posted_at"`, // TIMESTAMPTZ
edited: Maybe(time.Time) `db:"edited_at"`, // nullable — NULL is None, not a zero time
secret: string `db:"-"`, // skipped: no column at all
}
table.columnOdin types map to columns both directions — the DDL Mímir
carves, the parameter it binds, and the value scan hydrates
back:
Mímir's vocabulary is the well's. Build a statement with a verb, then run
it against a Well — the handle to the live connection,
drawn from the Bifrost (well(b)) or the App
(well(app)).
recall starts a SELECT over a model's columns;
chain whose (WHERE), join, order_by,
and limit, then sql to freeze it into a
statement. In whose, write ? for each value —
Mímir renumbers them to $1, $2, … and ships the values
separately. That is the SQL-injection checkpoint, and it holds no
matter what the value is.
w := gh.well(b) q := gh.recall(w, Article) gh.join(&q, "JOIN users ON users.id = articles.author_id") gh.whose(&q, "users.name = ? AND posted_at > ?", name, since) // ? -> $1, $2 gh.order_by(&q, "posted_at DESC") gh.limit(&q, 20) rows, ok := gh.query(w, gh.sql(&q)) // query -> Pg_Rows; exec -> bool (no rows) articles := gh.scan(rows, Article) // []Article, fields hydrated by db: name
scan hydrates every row into a []T;
scan_one returns the first with an ok for the
empty case. Columns match fields by mapped name — an unmatched column is
ignored, an unmatched field left zero — and a Maybe(T) field
distinguishes a real NULL from an empty value.
A failed query isn't just false: Pg_Rows.err
carries the Postgres SQLSTATE, so a handler can tell a
constraint violation from an outage and answer accordingly.
rows, ok := gh.query(w, gh.offer(w, Article{slug = slug, body = body}))
if !ok {
if gh.failed(rows) && rows.err.code == "23505" { // unique_violation
gh.text(b, 409, "slug already taken")
} else {
gh.text(b, 503, "database unavailable")
}
return
}
id, _ := strconv.parse_int(rows.rows[0][0]) // the RETURNING id
// amend / forget target the row by its primary key.
gh.exec(w, gh.amend(w, Article{id = id, body = "edited"}))
gh.exec(w, gh.forget(w, Article{id = id}))
tx pins one pooled connection, wraps your closure in
BEGIN/COMMIT, and rolls back if it returns
false — so a batch is all-or-nothing. (For a transaction that
must span several handlers, pin/unpin hold a
dedicated connection across calls.)
ok := gh.tx(w, proc(w: gh.Well) -> bool {
_, a := gh.query(w, gh.offer(w, Article{slug = "a"}))
_, b := gh.query(w, gh.offer(w, Article{slug = "b"}))
return a && b // either insert failing rolls back both
})
remember hands Mímir a model; at run(),
migrate carves any missing tables and ALTERs in
any new columns you've added since — additive, non-destructive, logged.
Set db_type to .Postgres, .MySQL, or
.SQLite for the dialect. DDL is generated for all three;
Postgres and SQLite have live drivers (SQLite is opt-in:
-define:GJ_SQLITE=true, links libsqlite3), while MySQL is
DDL-only for now.
// SQLite: same query/exec/scan/tx path, against a file or :memory: app := gh.new(gh.Config{ db_type = .SQLite, sqlite = "app.db" }) // odin build . -define:GJ_SQLITE=true
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), auto-migrates every remembered model, and pools the
connections your handlers draw with well.
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 ./docs at /docs — this page lives here. gh.hail(&app, "/docs", "./docs")
Static files are cacheable: each carries an
ETag (size + mtime), Last-Modified, and
Cache-Control, and a still-fresh conditional re-request
(If-None-Match / If-Modified-Since) is answered
304 Not Modified with no body — so a returning visitor
re-downloads nothing until a file changes. And if a precompressed
<file>.gz sits beside the original, it's served with
Content-Encoding: gzip to clients that accept it
(gzip_static-style) — compression done ahead of time, since a
default build takes no third-party deps.
A handler often needs to call out — a payment gateway, a webhook,
another service. fetch is a small outbound HTTP(S) client
built from the same pieces as the server: net for the socket,
wire_send/wire_recv (which already abstract
plaintext vs TLS), and parse_headers for the reply. It sends
Connection: close, reads to EOF, and de-chunks a chunked
response.
// The zero-value request is a plain GET. res, ok := gh.fetch("https://api.example.com/v1/things") if ok && res.status == 200 { payload: My_Type json.unmarshal(res.body_bytes, &payload) // res.body is the same bytes as string } // Method, headers, body, timeout via Fetch_Request; reply headers are lower-cased. res, ok = gh.fetch("https://api.example.com/things", gh.Fetch_Request{ method = "POST", headers = {"Authorization" = "Bearer …"}, body = `{"name":"skuld"}`, }) // fetch_json marshals the payload and sets Content-Type: application/json. res, ok = gh.fetch_json("POST", "https://api.example.com/things", My_Type{})
ok is false only on a transport failure
(DNS, connect, TLS, or no parseable response) — a 4xx/5xx
still returns ok=true with the status set. Redirects are
returned, not followed (read res.headers["location"]). An
https:// URL needs a -define:GJ_TLS=true build —
the same OpenSSL gate as the DB and server — and without it an https fetch
fails fast instead of falling back to plaintext.
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: output an
expression, pipe it through filters, branch with if, iterate
with for, and compose with extends /
block / include. Parsed templates are cached by
path + mtime.
<!-- 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>
Macros are reusable fragments — define one with
{% macro %} and call it like a function; {% import %}
pulls another file's macros in. A macro sees only its arguments (not the
caller's locals), and its output is treated as markup, while
{{ param }} interpolations inside it are still escaped:
{% macro field(name, label) %}
<label>{{ label }} <input name="{{ name }}"></label>
{% endmacro %}
{{ field("email", "Email") }} // -> <label>Email <input name="email"></label>
{{ … }} is
HTML-escaped unless its filter pipeline ends in | safe. The
decision rides alongside each value as it's evaluated, so it's made per
output, never globally. Template paths are supplied by the handler, not
the request — the user-path traversal checkpoint lives in hail.
Flip one config flag and Gjallarhorn serves a docs page —
a Loom template carried in the binary — plus an openapi.json
document, both generated from the route table the router already holds.
Nothing to keep in sync: register a route and it appears. It's
off by default, so a public app pays nothing until it opts
in.
app := gh.new(gh.Config{
port = 8091,
docs = gh.Docs_Config{
enabled = true, // off by default
title = "My API",
version = "1.2.0",
},
})
// Give a route its schema — reflected into JSON Schema + an example body.
gh.get(&app, "/sample/:id", get_handler)
gh.describe(&app, .Get, "/sample/:id", {summary = "Fetch one sample", response = Sample})
gh.post(&app, "/sample", create_handler)
gh.describe(&app, .Post, "/sample", {request = Sample, response = Sample})
The page is interactive: each endpoint expands to a
Try it panel with inputs for its path parameters, an editable JSON
request body, and an Execute button that fires the request
from the browser — a plain same-origin fetch —
and shows the live status and response. Ward-guarded routes carry a 🔒.
Alongside it, /api-docs/openapi.json is a valid
OpenAPI 3.0.3 document: :id becomes an
{id} path parameter, methods on a shared path are grouped, and a
guarded route advertises a 401.
describe points a route at its Odin request/response types, and
Gjallarhorn reflects them into schema and example values with the same
reflection Mímir uses — a Maybe(T) field becomes a
nullable property, nested structs and slices recurse, and
property names follow a json: tag or the field name. It's
additive: an undescribed route still lists, just without a body schema, and
what isn't described is omitted rather than invented. Because Execute sends a
real request it passes through your middleware — a POST
behind the csrf rune gets the same 403 a browser
would — so what you see is the truth. Point Swagger UI or
openapi-generator at the spec URL too.
The gjallarhorn command bootstraps projects and generates
code. new scaffolds a runnable app — a minimal
main.odin, a docker-compose.yml, and a vendored
copy of the framework — so it runs immediately.
run and build wrap odin run . /
odin build . from the app's directory (flags forwarded).
generate resource writes a full CRUD trio
(model + controller + routes)
mirroring ./sample.
# bootstrap a new, runnable app gjallarhorn new blog cd blog && gjallarhorn run # = odin run . -> http://127.0.0.1:8091 # add a CRUD resource (alias: g res). Name is plural; the model is singular. gjallarhorn generate resource users # -> users/{model,controller,routes}.odin
The resource's register(app) hands its model to Mímir and
wires GET /users/:id, POST /users,
PUT /users/:id, and DELETE /users/:id. Drop
users.register(&app) into main() and it's live.
The same command also load-tests a running app —
gjallarhorn bench load http://127.0.0.1:8091/ -c 50 -d 5 —
a self-contained generator reporting req/s and latency percentiles.
And it documents itself: gjallarhorn docs opens a
terminal UI (raw-mode termios + ANSI, no dependencies) that
browses the whole framework by topic — Mímir, Loom, the runes, sessions,
fetch — as a two-pane table. gjallarhorn docs loom jumps to a
topic; gjallarhorn docs --plain dumps text for piping. Arrows or
j/k move, space/b page,
q quits.
On Arch Linux, install the gjallarhorn command from the AUR —
it ships the framework source, which gjallarhorn new vendors
into your project:
# Arch Linux (AUR) yay -S gjallarhorn-git # or: paru -S gjallarhorn-git gjallarhorn new blog
Or work from a checkout — the framework is a single dependency-free Odin package, so there's nothing to fetch but the compiler:
# From a git checkout git clone https://github.com/Lvcky-gg/Gjallarhorn.git && cd Gjallarhorn docker compose up -d # optional: Postgres for the ORM odin run . # serves the sample on :8091 odin test ./tests # the test suite odin build cli -out:gh # build the CLI yourself
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,
secret = "change-me-before-shipping", // signs sessions/CSRF
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.rune(&app, gh.rate_limit) // 429s abusive clients
gh.rune(&app, gh.csrf)
gh.hail(&app, "/docs", "./docs") // serve files
sample.register(&app) // remember models + routes
gh.run(&app) // connect, migrate, listen, drain on SIGTERM
}
| GET | /sample/schema | inspect the DDL & SQL Mímir generates |
| GET | /sample/:id | read a row |
| POST | /sample | create from a JSON body — returns the new id |
| PUT | /sample/:id | update from a JSON body |
| DELETE | /sample/:id | forget by id |
| POST | /login · /logout | session login flow (CSRF-guarded) |
| GET | /account | behind the require_login ward |
| POST | /upload | multipart/form-data file upload |
Bodies are JSON, url-encoded, or multipart — bound with
bind_json, form, and upload.