A simple web framework for odin
Summary
Gjallarhorn is a from-scratch web framework for the Odin programming language, providing an HTTP server, router, middleware, template engine, and ORM with PostgreSQL support, all with no external dependencies.
View Cached Full Text
Cached at: 06/27/26, 03:48 AM
Lvcky-gg/Gjallarhorn
Source: https://github.com/Lvcky-gg/Gjallarhorn
ᚷ Gjallarhorn
A from-scratch web framework in Odin. No dependencies.
Gjallarhorn is the horn Heimdall sounds at the gates of Ásgarð. Here it’s a small, honest web framework: a hand-rolled HTTP server, a router, an onion of middleware, a template engine, and an ORM that speaks PostgreSQL over a wire protocol written from scratch. No libpq, no third-party packages — just structs, runes, and the well of memory.
It is young and says so. Each module documents what it does, what its security checkpoint is, and what’s deferred to a later phase. See Status & limitations for an unvarnished account of what works today.
Quickstart
You need the Odin compiler on your path. A database is optional — without one, the framework still runs and just prints the SQL it would migrate.
# 1. (optional) bring up Postgres for the ORM
docker compose up -d
# 2. run the sample app — serves on http://127.0.0.1:8091
odin run .
# 3. run the template-engine tests
odin test ./tests
If you skip step 1, leave dbname empty in main.odin. Migrations then print
their DDL to stdout instead of executing, and the DB-backed routes return
503 — everything else (routing, middleware, static files, templates) works.
Try the running sample:
curl http://127.0.0.1:8091/sample/schema # the SQL Mímir builds for the model
curl http://127.0.0.1:8091/sample/7 # a Sample row as JSON
curl http://127.0.0.1:8091/pages/hello.html # a Loom-rendered template
curl http://127.0.0.1:8091/docs # the static docs site
Install — use it in your own project
Odin has no central package manager: you install a package by putting its source
where the compiler can import it. Gjallarhorn depends only on core: and base:
(no third-party packages), so there is nothing else to fetch.
Option A — vendor the directory (simplest). Copy or clone just the framework
package into your project next to your main.odin, then import it by folder name:
your-app/
├── main.odin
└── gjallarhorn/ # copy of this repo's gjallarhorn/ package directory
import gh "gjallarhorn" // resolves to the ./gjallarhorn subdirectory
Option B — git submodule + a collection. Track the repo and point an Odin
collection at it, so updates are a git pull away. The submodule checks out the
whole repo, whose framework lives in its inner gjallarhorn/ package directory:
git submodule add https://github.com/lvcky-gg/gjallarhorn vendor/gjallarhorn
# framework package is now at vendor/gjallarhorn/gjallarhorn
import gh "shared:gjallarhorn" // resolves to <collection-root>/gjallarhorn
# point the `shared` collection root at the repo checkout, so `shared:gjallarhorn`
# lands on its inner package directory
odin build . -collection:shared=vendor/gjallarhorn
Either way, build as usual. To turn on TLS (DB or HTTPS) add the opt-in flag — see TLS / HTTPS:
odin build . # plaintext; no OpenSSL dependency
odin build . -define:GJ_TLS=true # links system libssl for TLS
Requirements: the Odin compiler on your
path, and — only for a -define:GJ_TLS=true build — system OpenSSL
(libssl/libcrypto).
A minimal app
package main
import gh "gjallarhorn"
User :: struct {
id: int `db:"id,pk,auto"`,
name: string `db:"name,notnull"`,
}
hello :: proc(b: ^gh.Bifrost) {
name, _ := gh.param(b, "name")
gh.json(b, 200, User{id = 1, name = name})
}
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",
},
})
// Middleware ("runes"), registered onion-style, outermost first.
gh.rune(&app, gh.logger)
gh.rune(&app, gh.cors)
// Let Mímir remember the model; its table is auto-migrated at run().
gh.remember(&app, User)
// Routes.
gh.get(&app, "/hello/:name", hello)
// Mount a static dir and a template dir.
gh.hail(&app, "/static", "./public")
gh.hail(&app, "/pages", "./templates", page_context)
gh.run(&app)
}
page_context :: proc(b: ^gh.Bifrost) -> gh.Warp {
return gh.warp(
{"title", "Hello"},
{"items", gh.list("urd", "verdandi", "skuld", allocator = context.temp_allocator)},
allocator = context.temp_allocator,
)
}
The pieces
Odin is one-directory-one-package, so the whole framework lives in
package gjallarhorn across several files. Each feature keeps its registration
verb next to its logic.
| File | What it holds |
|---|---|
app.odin | App / Config / new |
server.odin | listen / accept (one thread per connection) / request parsing / keep-alive |
router.odin | routes, get/post/put/delete, path matching + dispatch |
middleware.odin | the Rune chain: rune, next, and built-in cors, logger |
bifrost.odin | the request/response object and its helpers |
body.odin | request-body decoders: bind_json, form, query/percent decoding |
response.odin | writing HTTP/1.1 responses |
session.odin | signed-cookie sessions + cookie / set_cookie |
static.odin | hail + traversal-safe file serving |
loom.odin | HTTP glue for Loom: render, html, directory mounts |
loom/ | Loom, the template engine (package loom) |
mimir.odin | Mímir, the ORM (writes and reads — scan hydrates rows into structs) |
postgres.odin | a from-scratch PostgreSQL v3 wire-protocol client (SCRAM auth, pooling) |
tls.odin | optional OpenSSL TLS for the DB connection and the HTTP server (opt-in) |
Routing
Method verbs register routes; :name segments capture into params.
gh.get(&app, "/sample/:id", get_handler)
gh.post(&app, "/sample", create_handler)
gh.put(&app, "/sample/:id", update_handler)
gh.delete(&app, "/sample/:id", delete_handler)
Inside a handler, the Bifrost is your request and response:
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"})
}
Literal routes should be registered before :param routes that could shadow them
(/sample/schema before /sample/:id).
Reading request data. The Bifrost exposes every part of the request through
small helpers; most return (value, ok) so a missing field is explicit:
id, ok := gh.param(b, "id") // path segment /sample/:id
id, ok := gh.param_int(b, "id") // same, parsed to int (ok=false if NaN)
q, ok := gh.query_param(b, "page") // query string ?page=2
ua, ok := gh.header(b, "user-agent") // request header (keys are lower-cased)
Bodies are decoded on demand. bind_json unmarshals the body into a struct and
writes a 400 for you on malformed input; form decodes an
application/x-www-form-urlencoded body into a map:
create :: proc(b: ^gh.Bifrost) {
payload: struct { name: string }
if !gh.bind_json(b, &payload) { return } // 400 already written on bad JSON
gh.json(b, 201, User{name = payload.name})
}
login :: proc(b: ^gh.Bifrost) {
fields := gh.form(b) // map[string]string
user, pass := fields["user"], fields["password"]
// ...
}
The raw body is also on the Bifrost as b.body ([]u8) and b.body_text
(string) if you need to decode it yourself. Bodies are framed by
Content-Length and capped at Config.max_body (default 1 MiB), beyond which the
server returns 413 before your handler runs.
Writing the response. text, json, and html set the status, content
type, and body in one call; set_header adds a response header; not_found
writes a 404. The first write wins — a second text/json on the same Bifrost
is a no-op, so an early return after writing is safe.
Cookies. cookie(b, name) reads from the request; set_cookie queues a
Set-Cookie (each call its own header line, so several cookies coexist):
sid, ok := gh.cookie(b, "session")
gh.set_cookie(b, "session", token, gh.Cookie_Options{
http_only = true, secure = true, same_site = .Lax, max_age = 3600,
})
gh.set_cookie(b, "session", "", gh.Cookie_Options{max_age = 0}) // delete
max_age is a Maybe(int): omit it for a session cookie, 0 to expire now.
Values are stored verbatim — encode any value carrying ;, ,, =, or
whitespace yourself.
Sessions ride in a signed cookie — a string->string map the client holds,
tamper-proofed with an HMAC-SHA256 tag keyed by Config.secret. The server keeps
no state; a forged or edited cookie reads back as an empty session.
gh.session_set(b, "user", "freyja") // re-signs the cookie
name, ok := gh.session_get(b, "user")
gh.session_clear(b) // empties + expires the cookie
Set Config.secret in production — when it’s empty, sessions fall back to a
fixed, public default key and new() warns at startup.
Middleware (Runes)
A Rune wraps the rest of the pipeline. Odin has no closures, so the remaining
chain is threaded through the Bifrost rather than captured — call next(b) to run
the next layer.
auth :: proc(b: ^gh.Bifrost, next: gh.Next) {
// ...inspect the request, maybe short-circuit...
next(b) // or don't, to stop the chain
}
gh.rune(&app, auth)
Built-ins: logger (one line per request) and cors (permissive CORS +
preflight OPTIONS short-circuit).
Mímir — the ORM
Your structs describe a shape; db: tags drive everything. Mímir remembers the
shape and migrates it to a table at run().
Sample :: struct {
id: int `db:"id,pk,auto"`, // auto-assigned primary key
name: string `db:"name,notnull"`, // required text column
}
Tag flags: pk, auto, unique, notnull, a custom column name, or - to
skip a field. The query verbs follow the well’s vocabulary:
| Verb | SQL | Meaning |
|---|---|---|
carve | CREATE TABLE | carve a struct’s shape into the well |
offer | INSERT | offer a value to the well |
recall | SELECT | recall rows (a Query you refine, then sql) |
amend | UPDATE | amend a remembered row by primary key |
forget | DELETE | make the well forget a row by primary key |
w := gh.well(b)
q := gh.recall(w, Sample)
gh.whose(&q, "id = ?", 7)
gh.limit(&q, 1)
rows, ok := gh.query(w, gh.sql(&q))
Reading rows back into structs. query returns Pg_Rows (text cells);
scan hydrates every row into a freshly allocated []T, and scan_one returns
just the first row with an ok for the empty case. Columns map to fields by db:
name (else the field name); a SQL NULL becomes the field’s zero value.
rows, ok := gh.query(w, gh.sql(&q))
users := gh.scan(rows, User) // []User
one, found := gh.scan_one(rows, User) // (User, bool)
Supported field types: the int family, f32/f64, bool, and string.
Writes use the same query verb with offer/amend/forget, or exec
when you don’t need the returned rows:
gh.query(w, gh.offer(w, User{name = "freyja"})) // INSERT
gh.query(w, gh.amend(w, User{id = 1, name = "renamed"})) // UPDATE by pk
gh.query(w, gh.forget(w, User{id = 1})) // DELETE by pk
Transactions. tx checks out one pooled connection, wraps your closure in
BEGIN/COMMIT, and rolls back if it returns false (or any statement fails).
Every statement on the handed-in Well runs on that one connection:
ok := gh.tx(w, proc(w: gh.Well) -> bool {
_, a := gh.query(w, gh.offer(w, User{name = "a"}))
_, b := gh.query(w, gh.offer(w, User{name = "b"}))
return a && b // either insert failing rolls back both
})
SQL injection is the checkpoint here: values never reach the SQL string. Every
value is a bound parameter ($1.. for Postgres, ? otherwise).
Set db_type to .Postgres, .MySQL, or .SQLite. DDL is generated for all
three; the live driver today is Postgres (see limitations).
Postgres — a hand-rolled wire client
postgres.odin implements the PostgreSQL v3 frontend/backend protocol directly
over core:net: StartupMessage, the extended query flow (Parse / Bind / Describe
/ Execute / Sync), and RowDescription/DataRow parsing. Connections are pooled
(Config.pool_size, default 4) and checked out per request.
Auth: trust, cleartext, MD5, and SCRAM-SHA-256 — the default for stock
modern Postgres — so no pg_hba.conf downgrade is needed to connect to a
default-configured server.
TLS (optional). Set Postgres_Config.sslmode to negotiate TLS before the
startup handshake, so the password and all queries cross the wire encrypted:
postgres = gh.Postgres_Config{
host = "db.example.com", port = 5432,
user = "app", password = "secret", dbname = "gjallarhorn",
sslmode = .Require, // .Disable (default) / .Prefer / .Require / .Verify_Full
}
sslmode | Behaviour |
|---|---|
.Disable | no TLS; cleartext (the default — unchanged behaviour) |
.Prefer | use TLS if the server offers it, else fall back to cleartext |
.Require | TLS mandatory; certificate not verified |
.Verify_Full | TLS mandatory; verify the cert chain + hostname against the system CA bundle |
Any mode other than .Disable requires a TLS build — see
TLS / HTTPS below. Without it, startup fails loudly rather than
silently sending the password in the clear.
Loom — the template engine
A Jinja subset, pared to its load-bearing parts. The Norns weave fate at the well;
here weave runs your data (the weft) through a template (the warp).
<h1>{{ title }}</h1>
<p>Hail, {{ user.name }}{% if user.admin %} <strong>(admin)</strong>{% endif %}.</p>
{% if items %}
<ol>
{% for item in items %}
<li>#{{ loop.index }} — {{ item | upper }}{% if loop.last %} (last){% endif %}</li>
{% endfor %}
</ol>
{% else %}
<p>Nothing woven yet.</p>
{% endif %}
Supported: {{ expr }}, filter pipelines (upper, lower, trim, capitalize,
length, default, join, first, last, safe, escape), {% if %} /
{% elif %} / {% else %}, {% for x in xs %} with {% else %} for the empty
case and a Jinja-style loop (index, index0, first, last, length), and
{# comments #}. A - on either delimiter ({%- … -%}, {{- … -}}, {#- … -#})
trims adjacent whitespace, so tags don’t leave stray newlines.
Template inheritance — a child names a base with {% extends "base.html" %}
and overrides its named blocks; blocks it leaves alone keep the base’s default:
<!-- base.html -->
<html><body>{% block content %}default{% endblock %}</body></html>
<!-- page.html -->
{% extends "base.html" %}
{% block content %}<h1>{{ title }}</h1>{% endblock %}
Bases resolve against the template’s own mount dir, and {% extends %} chains
(grandchild → child → base) — the most-derived override of a block wins.
Includes — {% include "partial.html" %} renders another template inline
with the current context (loop vars and all). Partials resolve their own
inheritance, so an included file may itself {% extends %} a base.
Path traversal is the checkpoint here too: extends/include names are
clamped to the mount dir (same clean-and-contain check as the static mounts), so
{% include "../../etc/passwd" %} is refused.
Templates served from disk are parsed once and cached by path + mtime, so a hot page is lexed and parsed only on its first hit and re-parsed only when the file changes — the per-render cost drops to evaluation and output.
Typed rows render directly. warp_of reflects a struct (a scanned Mímir row,
say) into a context keyed by field name — no hand-built map:
gh.render(b, "user.html", gh.warp_of(row)) // {{ name }}, {{ admin }}
gh.warp({"users", gh.value_of(rows)}) // a slice -> {% for u in users %}
Nested structs become nested contexts ({{ profile.city }}) and slices iterate.
A loom:"alias" field tag renames the key for templates; loom:"-" hides it.
XSS is the checkpoint here: output is HTML-escaped by default. Safety rides
with the value as it’s evaluated, so it’s decided per output — pipe through
| safe to emit verbatim.
Static files
gh.hail(&app, "/static", "./public") // raw files
gh.hail(&app, "/pages", "./templates", provider) // files woven by Loom
Path traversal is the checkpoint here: a resolved path is cleaned and must
stay inside the mount root, else 403.
TLS / HTTPS
TLS is opt-in at build time. Odin ships no TLS in core or vendor, so
Gjallarhorn binds the system OpenSSL (libssl/libcrypto) — but the whole
binding lives behind a compile flag, so a default build links no libssl and
doesn’t need OpenSSL installed at all:
odin run . # plaintext: no OpenSSL needed, nothing linked
odin run . -define:GJ_TLS=true # links libssl; enables DB TLS + HTTPS
A TLS build needs OpenSSL’s development library present at build time and the
runtime library at run time (libssl + libcrypto, 1.1.x or 3.x). If you
configure TLS but build without -define:GJ_TLS=true, startup fails loudly
instead of silently falling back to an unencrypted connection.
HTTPS server. Point Config at a PEM certificate and key; the listener then
serves https:// instead of http://:
app := gh.new(gh.Config{
port = 8443,
tls_cert = "server.crt", // PEM certificate chain
tls_key = "server.key", // PEM private key
})
gh.run(&app) // logs: listening on https://127.0.0.1:8443
odin run . -define:GJ_TLS=true
For local testing, a self-signed pair is enough:
openssl req -new -x509 -days 365 -nodes \
-out server.crt -keyout server.key \
-subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
curl --cacert server.crt https://localhost:8443/ # verifies the chain
curl -k https://localhost:8443/ # or skip verification
Database TLS uses the same build flag — set Postgres_Config.sslmode (see
Postgres). The DB client and HTTP server
share one tls.odin module.
Scope: a single listener is HTTP or HTTPS (no dual-port and no HTTP→HTTPS
redirect); .Verify_Full trusts the system CA bundle (no custom-CA option yet);
certs load once at boot (no hot reload on renewal).
Lifecycle
A request crosses Bifrost in order: the socket loop in server.odin parses the
request line and builds a Bifrost, the rune chain runs outermost-first via
next, and when the chain is exhausted dispatch_route matches a route (or a
static/template mount) and calls the handler, which writes the response back
through the same Bifrost.
Status & limitations
Gjallarhorn has grown a coherent spine and several genuinely above-toy pieces: a from-scratch Postgres client (SCRAM auth, pooling, optional TLS), a real template engine with correct escaping and inheritance, full request parsing, and an ORM that reads typed rows back into structs. It is still young and not battle-tested for production — but the headline gaps the early drafts listed are now closed.
Working today: request headers, bodies (JSON + form) and query params; HTTP keep-alive; one-thread-per-connection concurrency; configurable bind address; per-request panic recovery; cookies and signed-cookie sessions; the ORM’s full read/write/transaction path with struct hydration; SCRAM-SHA-256 auth; connection pooling; optional TLS on both the DB connection and the HTTP server; template inheritance, includes, whitespace control, the compiled-node cache, and direct struct rendering.
Known gaps, in rough order of impact:
- Postgres-only in practice. MySQL and SQLite generate DDL but have no live
driver yet, so
query/execonly run against Postgres. - No auth-guard middleware or CSRF protection yet — the
.wardin the sample is still a TODO. Sessions and cookies exist to build these on. - Templates have no
{% macro %}. Inheritance, includes, and whitespace control are in; macros are not. - TLS is opt-in and depends on system OpenSSL (by design — a default build has
no TLS and no libssl).
.Verify_Fulltrusts only the system CA bundle, and certs are loaded once at boot. - No structured logging or CI yet (the built-in
loggerwrites one plain line per request).
A full backlog with fix guides lives alongside this project (backlog.md).
Contributions toward any of the above are the most useful place to start.
Project layout
.
├── gjallarhorn/ # the framework (package gjallarhorn)
├── sample/ # a small MVC example app
├── templates/ # Loom templates served at /pages
├── docs/ # the static docs site served at /docs
├── tests/ # Loom engine tests (odin test ./tests)
├── docker-compose.yml # a local Postgres for the ORM
└── main.odin # wires the sample app together
Similar Articles
Tom's Namespaces: An Odin Fanfic
A narrative-style technical post exploring code organization in the Odin programming language, highlighting its package system, the lack of package-local namespaces, and the resulting developer workflow challenges.
Odin dev-2026-06 Released
Odin dev-2026-06 has been released. Odin is a data-oriented programming language designed for high-performance systems development.
Yggdrasil Network as an Embedded Go Library
This article demonstrates how to embed Yggdrasil Network as a library in Go applications, allowing for user-space IPv6 mesh networking without a daemon.
Show HN: OpenGravity – A zero-install, BYOK vanilla JS clone of Antigravity
OpenGravity is a zero-install, vanilla JS clone of Google Antigravity that enables BYOK agentic coding using Gemini models and WebContainer API. Built to bypass rate limits, it offers a browser-based IDE with autonomous task orchestration and local file sync.
A Forth-inspired language for writing websites
Beto introduces Forge, a Forth-inspired stack-based language for building websites, featuring both server-side and client-side rendering via a WebAssembly compiler.