A from-scratch web framework in Odin

Heimdall is listening.

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.

The bridge from request to response

What Gjallarhorn is

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.

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

Bifrost, the bridge

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

Runes, the middleware

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

Sessions & guards

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

Mímir, the well

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

Loom, the templates

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 gjallarhorn CLI

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/

From socket to status code

The path a request walks

  1. 1

    Heimdall accepts

    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.

  2. 2

    The runes wrap

    next(&b) advances an index through the registered middleware — logger, cors, csrf — each free to act before and after the call downstream.

  3. 3

    Dispatch matches

    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.

  4. 4

    The handler answers

    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.

Free procedures, explicit app

Routing & request bodies

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)})
    }
}
An onion you thread, not capture

Runes — the middleware chain

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
Fail closed, sign everything

Sessions, CSRF & guards

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)
passwordsArgon2idOWASP cost, per-password salt, constant-time verify, PHC format
sessionssigned cookieHMAC-SHA256, server-enforced expiry, Secure over TLS
csrfsynchronizer tokenper-session token in header or form field, constant-time compare
wardsroute guardsproc(b) -> bool run before the handler; 401/403 on deny
rate_limittoken bucketper-client burst + refill; 429 with Retry-After on excess
smugglingframingreject dup / CL+TE headers & non-canonical lengths; strict chunked
CRLFresponseheader/cookie writes strip CR/LF; templates escape by default
Óðinn gave an eye for a single draught

Mímir — the ORM and the well of memory

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.

The model — 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
}
"name"columnthe column name; the field name is used if you omit it
pkPRIMARY KEYthe primary key amend / forget target by
autoauto-assignserver-generated id (BIGSERIAL) — omitted from inserts
notnullNOT NULLrequired column
uniqueUNIQUEunique constraint
fk:t.cREFERENCESforeign key to table.column
-skipnot persisted — no column for this field

Odin types map to columns both directions — the DDL Mímir carves, the parameter it binds, and the value scan hydrates back:

int · i8…i64 · u8…u64BIGINTthe whole integer family
f32 · f64DOUBLE PRECISIONfloating point
boolBOOLEANt / f on the wire
stringTEXTUTF-8 text
time.TimeTIMESTAMPTZUTC instant, offset folded out on read
[]u8BYTEAraw bytes, hex-framed
gh.UuidUUIDcore:encoding/uuid Identifier
gh.JsonJSONBraw JSON text, validated server-side
Maybe(T)nullableSQL NULL ⇢ None, value ⇢ Some(v) — never conflated

The five verbs

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)).

carveCREATE TABLEcarve a struct's shape into the well (migrate does this for you)
offerINSERToffer a value; RETURNING the new pk on Postgres
recallSELECTbegin a Query, refined with whose / join / order_by / limit
amendUPDATEamend a remembered row by its primary key
forgetDELETEmake the well forget a row by its primary key

Reading — the query builder

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.

Writing, and reading the error

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}))

Transactions

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
})

Migrations

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
Offline by design. Leave 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.
Files, served safely

Static mounts

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.

Sounding out — calling other APIs

Fetch — the outbound client

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.

The Norns weave the threads of fate

Loom — the templating engine

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>
{{ expr }}outputrender an expression, HTML-escaped by default
| filterpipelineupper · lower · capitalize · default · join · length · first · last · safe
{% if %}branchif / elif / else / endif
{% for %}iteratefor x in xs … else (empty case) … endfor, with a loop binding
extendsinheritanceextends / block / include compose templates
{% macro %}reusedefine once, call as {{ name(args) }}; {% import %} across files

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>
XSS is the checkpoint. Every {{ … }} 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.
The framework describes itself

OpenAPI docs — woven by Loom, with a live "Try it"

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.

Scaffolding, nest-style

The gjallarhorn CLI

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.

Sound the horn

Install

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
The whole ceremony

Quickstart

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
}

The sample API

GET/sample/schemainspect the DDL & SQL Mímir generates
GET/sample/:idread a row
POST/samplecreate from a JSON body — returns the new id
PUT/sample/:idupdate from a JSON body
DELETE/sample/:idforget by id
POST/login · /logoutsession login flow (CSRF-guarded)
GET/accountbehind the require_login ward
POST/uploadmultipart/form-data file upload

Bodies are JSON, url-encoded, or multipart — bound with bind_json, form, and upload.