Skip to content

Architecture

This document describes the planned architecture for the axon-web HTTP framework. It covers the design decisions made before implementation begins so they can be reviewed and challenged.

Status: Architecture defined, implementation not yet started (M14 not begun).

  1. High-Level Architecture
  2. HTTP Router
  3. Middleware Pipeline
  4. JSON Module
  5. WebSocket
  6. Static File Serving
  7. HTML Templating
  8. Performance Design
  9. File Organization
  10. CI Infrastructure

axon-web is an Axon library, not an application. Users import axon.web and build their own HTTP applications:

;; User's application
(import axon.web)
(import axon.web.router)
(import axon.web.middleware)
(fn handle_home (ctx)
(web_response ctx 200 (web_html "<h1>Hello</h1>")))
(fn handle_about (ctx)
(web_response ctx 200 (web_html "<h1>About</h1>")))
(fn main () i32
(block
(web_server
(router
(get "/" handle_home)
(get "/about" handle_about))
(middleware_chain
(middleware.logger)
(middleware.cors)))))
raw bytes → zero-copy parser → middleware chain → router → handler → middleware chain → response

The parser extracts the request line and headers directly from the socket buffer (no String allocation). The middleware chain is a linked list; each middleware calls next() to pass control. The router matches the method+path and dispatches to the handler.

Both HTTP/1.1 and HTTP/2 are served over the same API. HTTP/2 uses the same middleware/router/handler model, with HPACK header compression and stream multiplexing handled beneath the handler layer.

axon-web uses axon-std modules:

  • mem — memory operations, buffer manipulation
  • string — string parsing and building
  • net — socket I/O (once available in axon-std)

Routes are stored in a radix tree (compressed trie) for O(k) matching where k = path length (not number of routes):

;; Tree structure for: GET /users/:id, GET /users/:id/profile, GET /files/*path
(root)
└── /users/
└── /:id
├── (match GET) → handle_get_user
└── /profile
└── (match GET) → handle_get_user_profile
└── /files/
└── /*path
└── (match *) → handle_static
PatternExampleMatches
Static/aboutExactly /about
Parameterized/users/:id/profile/users/123/profile
Wildcard/files/*path/files/docs/readme.txt

Each route node stores handlers by HTTP method:

(struct RouteNode
(children (map str RouteNode)) ;; path segment → child node
(param_child (option RouteNode)) ;; :param child
(wildcard_child (option RouteNode)) ;; *path child
(handlers (map method Handler))) ;; GET/POST/PUT/DELETE → handler

Built-in 404 (no match) and 405 (method not allowed) handlers return appropriate responses.


(fn middleware (ctx: WebContext, next: fn() -> Response) -> Response)

Middleware receives ctx (containing request/response state) and next (the next middleware or handler). It can:

  • Read/write ctx.request (headers, body, path params)
  • Read/write ctx.response (headers, status, body)
  • Short-circuit by returning early (e.g., static file middleware finds the file and returns)
  • Call next() to pass control downstream

Middleware is a linked list. middleware_chain([A, B, C], handler) produces:

A → B → C → handler
C returns
B returns
A returns
incoming: logger → cors → auth → router → handler
outgoing: handler → auth → cors → logger
MiddlewarePurposeShort-circuits?
loggerLogs request method, path, status, durationNo
corsAdds CORS headers (Access-Control-*)No
authValidates auth token, sets ctx.userYes (401)
static_filesServes files from root directoryYes (file or 404)
websocketUpgrades connection to WebSocketYes (if upgrade header)
;; Middleware can read request headers
(let (auth_header (web_req_header ctx "Authorization"))
(if (string.starts_with auth_header "Bearer ")
(ctx_set ctx "auth.token" (string.slice auth_header 7))))
;; Middleware can set response headers
(web_set_header ctx "X-Custom-Header" "value")
;; Middleware can short-circuit
(if (not (auth.valid ctx))
(return (web_response ctx 401 (web_json "{\"error\":\"unauthorized\"}"))))

;; Encode an Axon struct to JSON string
(json.encode value: T) -> String
;; Decode a JSON string to an Axon struct
(json.decode data: String, type: T) -> Result(T, JsonError)

JSON decoding parses directly from the request buffer — no intermediate String allocation:

;; Parse JSON body directly from the socket buffer
(let (buf (web_req_body_buffer ctx))
(match (json.decode buf User)
(ok user) (handle_user ctx user)
(err e) (web_response ctx 400 (web_json "{\"error\":\"invalid json\"}"))))
(enum JsonError
(syntax_error i32) ;; byte offset of error
(type_mismatch str) ;; expected type name
(missing_field str)) ;; field name

WebSocket starts as an HTTP request with upgrade headers:

GET /ws HTTP/1.1
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==

The websocket middleware detects these headers, performs the handshake, and upgrades the connection.

Frame typeAxon representation
Text(text String)
Binary(binary (slice u8))
Ping(ping)
Pong(pong)
Close(close u16 String)

Messages are buffered in full before delivery to the handler (not streaming). This simplifies the handler API at the cost of memory for large messages.

WebSocket upgrade goes through the middleware chain — logger, cors, auth all apply before the WebSocket upgrade occurs.


Files are looked up relative to a configured root directory:

;; If root = "/var/www" and path = "/images/logo.png"
;; → serve /var/www/images/logo.png

Path traversal attacks (../) are blocked by normalizing the path before lookup.

Extension-based MIME type mapping:

(enum MimeType
(text_html) ;; .html
(text_css) ;; .css
(application_js) ;; .js
(image_png) ;; .png
(image_jpeg) ;; .jpg, .jpeg
(application_octet_stream)) ;; fallback

Conditional requests using ETag and Last-Modified:

;; Client sends:
;; If-None-Match: "abc123"
;; Server responds:
;; 304 Not Modified (if ETag matches)
;; 200 OK + body (if ETag doesn't match)
;; ETag: "abc123"
;; Last-Modified: Wed, 21 Oct 2015 12:00:00 GMT

Directory listing is not supported — it is a security risk.


{{user.name}} <!-- variable substitution -->
{{#if user.admin}} <!-- conditional -->
<admin-panel />
{{/if}}
{{#each items as item}} <!-- iteration -->
<li>{{item}}</li>
{{/each}}

Templates are compiled to Axon code at build time, not parsed at runtime:

;; Compiled template produces:
(fn render_user_profile (user: User, items: (list Item)) String
(let (sb (string.builder)))
(string.append sb "<h1>")
(string.append sb (str.escape_html user.name))
(string.append sb "</h1>")
(if user.admin
(string.append sb "<admin-panel />"))
(for item items
(string.append sb "<li>")
(string.append sb (str.escape_html item))
(string.append sb "</li>"))
(string.to_string sb)))

Templates are compiled once, called many times with different data. No reflection at runtime — the type is known at compile time.


Request line and headers are parsed directly from the socket buffer, never copied to an intermediate String:

;; Parse "GET /path HTTP/1.1\r\n" directly from buffer
(let (method (buf.read_until buf ' ')
(path (buf.read_until buf ' '))
(version (buf.read_until buf '\r')))
;; method, path, version are slices into the buffer, not copies

CRLF detection (header delimiter) uses axon-std SIMD intrinsics for fast scanning of large buffers:

;; Uses SIMD intrinsic to find \r\n\r\n (header end) quickly
(let (header_end (buf.find_crlf_crlf buf))

Keep-alive connections reuse parser instances to avoid allocation overhead on high-throughput servers.

The framework binary should stay under a configurable size limit (enforced by perf:regression-check). Zero-copy parsing and avoiding unnecessary abstractions help keep binary size down.


When M14 code is written, the source tree will be organized as:

axon-web/
├── src/
│ ├── server.axs # Connection acceptor, HTTP/1.1 + HTTP/2 demultiplexing
│ ├── router.axs # Radix tree, route matching, method dispatch
│ ├── middleware.axs # Pipeline composition, built-in middleware
│ ├── context.axs # WebContext struct, request/response accessors
│ ├── json.axs # Encode/decode
│ ├── websocket.axs # Protocol upgrade, frame handling
│ ├── static.axs # File serving, MIME types, caching
│ ├── template.axs # Compiled template engine
│ ├── http2.axs # HPACK, stream multiplexing
│ └── tls.axs # OpenSSL FFI bindings
├── public/ # Landing page (deployed to GitLab Pages)
├── tests/
│ ├── test_router.py # Placeholder Python tests
│ ├── test_middleware.py
│ └── test_json.py
├── .gitlab-ci.yml
└── docs/
├── architecture.md # This file
├── CONTRIBUTING.md
└── ci.md

This structure is planned, not implemented. No src/ directory exists yet.


JobToolWhat it validates
lint:htmltidypublic/*.html (landing page, not framework)
lint:cssstylelint@15.11.0public/**/*.css (landing page)
lint:jseslint@8.57.0public/**/*.js (landing page)
pagesGitLab PagesDeploys public/ to GitLab Pages

All lint jobs have allow_failure: true — they validate the landing page, not the framework.

When .axs source files exist in src/, these template jobs from labs/axon/axon-ci will activate:

JobTemplatePurpose
test:axon-checkaxon-library.ymlAxon type checking
test:axon-formataxon-library.ymlCode formatting check
test:axon-e2eaxon-library.ymlCompile + run + golden file compare
test:axon-testaxon-library.ymlUnit tests
perf:regression-checkperf-thresholds.ymlBinary size and compile time gates

perf:regression-check currently has allow_failure: true because there are no real benchmarks yet. It will be enforced when axon-web#9 (performance metrics and benchmarks) is implemented.