Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

web - a small HTTP framework

import "web.j" as web;

An ergonomic HTTP framework over the httpd server engine. Register routes against handler methods by name, then web.run owns the accept loop, matches each request, and dispatches to the handler - so a web app reads as a set of handlers plus a route table, not a hand-written server loop.

Needs the default jennifer binary (the httpd engine is net-backed; the constrained jennifer-tiny has no network stack).

Handlers

A handler is a top-level method taking a web.Context:

import "web.j" as web;

func showUser(ctx as web.Context) {
    web.text($ctx, 200, "user " + web.param($ctx, "id"));
}

def app as web.App init web.new();
$app = web.get($app, "/users/:id", "showUser");
web.run($app, ":8080");

Because Jennifer has no first-class functions, a route stores the handler’s name and dispatch happens by name via meta.callMain - the primitive that lets the framework module reach a handler defined in the entry program across the module boundary. You never register a function value; you register a name, and web.get/web.route check at registration time that the method exists.

The web.Context

Everything a handler needs hangs off the web.Context it receives, so it rarely touches httpd directly:

CallReturns
web.method($ctx)stringRequest method.
web.path($ctx)stringRequest path.
web.param($ctx, name)stringA captured :param ("" if none).
web.query($ctx, name)stringA query-string parameter.
web.header($ctx, name)stringA request header.
web.body($ctx)bytesThe raw request body (binary-safe).
web.bodyJson($ctx)json.ValueThe request body decoded as JSON.
web.form($ctx)map of string to stringThe x-www-form-urlencoded body, decoded.
web.formValue($ctx, name)stringOne form field ("" if absent).
web.multipartForm($ctx)list of multipart.PartThe multipart/form-data body (file uploads), parsed via the multipart module. Import multipart.j in the handler to name multipart.Part / use multipart.isFile / multipart.text.
web.remoteAddr($ctx)stringThe client’s host:port.
web.setHeader($ctx, name, value)nullSet a response header (before responding).
web.respond($ctx, status, body)nullSend a response (body is a string).
web.text($ctx, status, body)nullRespond with text/plain.
web.html($ctx, status, body)nullRespond with text/html.
web.sendJson($ctx, status, doc)nullRespond with application/json from a json.Value.
web.sendGzip($ctx, status, body)nullRespond, gzip-compressed when the client accepts it.
web.redirect($ctx, status, location)nullRedirect (301/302/303/307/308) with a Location header.
web.serveFile($ctx, path)nullRespond with a file from disk.
web.serveDir($ctx, root)nullServe static files from a directory root.

Cookies

Pure HTTP-header work over httpd, no extra dependency:

CallReturns
web.cookie($ctx, name)stringThe request cookie’s value ("" if absent).
web.setCookie($ctx, name, value, opts)nullEmit a Set-Cookie response header.

opts is a web.CookieOptions - path, domain, maxAge (int seconds; 0 omits the attribute, negative expires the cookie now), httpOnly, secure, sameSite ("Lax" / "Strict" / "None" / ""). A zero-value struct is a plain session cookie. Several setCookie calls emit several Set-Cookie headers (they are not collapsed).

Sessions

web owns only the session id cookie; the session data lives in a store the app owns, so web forces no store or network dependency (it never imports session / memcache). That keeps a web app that uses no sessions at httpd + meta + json + collections.

CallReturns
web.sessionId($ctx, cookieName)stringThe request’s session id, minting a new UUID + HttpOnly / SameSite=Lax / path-/ cookie on first use.

Call web.sessionId once per request and pair the id with your own store - for multi-process serving the session module over memcache; for a single process anything the app holds:

# The app owns the store; web just resolves the id cookie.
func profile(ctx as web.Context) {
    def id as string init web.sessionId($ctx, "sid");
    def data as map of string to string init session.load($store, $id);   # app's store
    # ... read / write $data ...
    session.save($store, $id, $data, 3600);
    web.text($ctx, 200, "ok\n");
}

Stateless signed-cookie sessions (data in the cookie, no store) wait on a crypto library for real HMAC - see horizon.md.

Registering routes

Each registrar returns a new App (value semantics), so build the app by threading it through:

Call
web.new()An empty app.
web.get($app, pattern, handler)Register a GET route.
web.post / web.put / web.patch / web.deleteThe other verbs.
web.route($app, method, pattern, handler)The general form.
web.before($app, handler)Add a middleware (runs before each route handler).
web.notFound($app, handler)A custom handler for unmatched requests (default: a plain 404).

Patterns are /-separated. A segment beginning with : captures a single parameter: /users/:id/posts/:pid matches /users/7/posts/9 and captures id = 7, pid = 9. A trailing segment beginning with * is a wildcard that captures the entire remainder (joined by /, possibly empty): /files/*path matches /files/css/app.css with path = css/app.css, and /*path is a catch-all - a natural SPA fallback. Read either with web.param($ctx, name).

The first matching route wins, so register specific routes before a wildcard (a greedy /*path registered first would swallow everything). A wildcard is only special as the last pattern segment.

$app = web.get($app, "/static/*path", "serveStatic");   # nested static files
$app = web.get($app, "/*page", "spaIndex");              # fallback, registered last

Middleware

A middleware is a handler that runs before the route handler and returns a bool: true to continue, or respond and return false to halt (e.g. an auth gate):

func requireKey(ctx as web.Context) {
    if (web.header($ctx, "X-Api-Key") == "secret") {
        return true;
    }
    web.text($ctx, 401, "unauthorized\n");
    return false;
}

$app = web.before($app, "requireKey");

Every request is answered exactly once: if a handler throws or forgets to respond, the framework sends a 500 so the connection never hangs.

Authentication

web parses the incoming Authorization header; checking the credentials (against your user store) and sending the 401 challenge stay app code.

CallReturns
web.basicAuth($ctx)BasicCredentialsDecode Authorization: Basic base64(user:pass).
web.bearerToken($ctx)stringThe token from Authorization: Bearer <token> ("" if absent).

BasicCredentials is { user, password, present }; present is false when the header was missing or malformed. A Basic-auth gate is a middleware:

func requireLogin(ctx as web.Context) {
    def cred as web.BasicCredentials init web.basicAuth($ctx);
    if ($cred.present and checkUser($cred.user, $cred.password)) {
        return true;
    }
    web.setHeader($ctx, "WWW-Authenticate", "Basic realm=\"app\"");   # the 401 challenge is a two-liner
    web.text($ctx, 401, "unauthorized\n");
    return false;
}
$app = web.before($app, "requireLogin");

For bearer tokens, web.bearerToken($ctx) extracts the token; validate it yourself (an opaque lookup, or jwt.verify once the jwt module lands). Client -side auth (sending Authorization) lives in the rest module (rest.basic / rest.bearer). Digest auth is not supported (a legacy, challenge/nonce scheme; use Basic over TLS or a bearer token).

CSRF

Stateless, HMAC-signed double-submit tokens. web holds no secret or session state - the app supplies a secret (a stable per-deployment string) and opts in with a middleware. A token is <random>.<hmac(secret, random)>, minted into the csrf cookie and echoed by the client; a request is accepted only when the submitted token equals the cookie and its signature verifies, so a forger without the secret cannot mint one.

CallReturns
web.csrfToken($ctx, secret)stringMint a token, set the csrf cookie, return it for the form / an X-CSRF-Token header.
web.csrfCheck($ctx, secret)boolTrue when the request carries a valid token.

Mint the token in the GET handler that renders a form; guard the unsafe methods with a middleware:

def const CSRF as string init os.getEnv("CSRF_SECRET");   # a stable secret

func showForm(ctx as web.Context) {
    def token as string init web.csrfToken($ctx, CSRF);
    web.html($ctx, 200, "<form method=post><input type=hidden name=csrf value=" + $token + ">...</form>");
}

func guardCsrf(ctx as web.Context) {
    def m as string init web.method($ctx);
    if ($m == "GET" or $m == "HEAD") {
        return true;                       # safe methods
    }
    if (web.csrfCheck($ctx, CSRF)) {
        return true;
    }
    web.text($ctx, 403, "CSRF check failed\n");
    return false;
}
$app = web.before($app, "guardCsrf");

The submitted token is read from the X-CSRF-Token header (for JSON / fetch clients) or the csrf form field (for HTML forms).

CORS

web.cors($app, opts) -> App sets a cross-origin policy for the whole app. When it is set, the serve loop adds the Access-Control-* headers to every response and answers a preflight OPTIONS request with a 204 before routing - so CORS is a one-line, app-wide policy, not something each handler repeats.

def opts as web.CorsOptions;
$opts.allowOrigin = "*";
$opts.allowMethods = "GET, POST, PUT, DELETE, OPTIONS";
$opts.allowHeaders = "Content-Type, Authorization";
$app = web.cors($app, $opts);

opts is a web.CorsOptions - allowOrigin ("*" or a specific origin; "" leaves CORS off), allowMethods, allowHeaders, allowCredentials (bool), and maxAge (int seconds, 0 omits it). A zero-value struct is CORS off, so web.new() starts with no policy.

Caching

Static files are already cached: web.serveFile rides Go’s file server, which sets ETag / Last-Modified and answers If-None-Match / If-Modified-Since (and Range) on its own. Plain cache headers are a one-liner - web.setHeader($ctx, "Cache-Control", "max-age=3600") - so there is no wrapper for them.

For a dynamic response, web.etag($ctx, tag) -> bool handles the conditional GET: it sets the ETag header and, if the request’s If-None-Match matches, answers 304 Not Modified and returns true so the handler stops before sending the body. tag is your choice of validator - a content hash (via hash), a database row version, an mtime - so web needs no hashing of its own.

func page(ctx as web.Context) {
    def body init render();
    def tag init hashOf($body);           # your validator
    if (web.etag($ctx, $tag)) {
        return;                            # 304 sent; skip the body
    }
    web.text($ctx, 200, $body);
}

The match is a simple exact / * comparison; the RFC 7232 comma-list and weak (W/) tag forms are not parsed.

Compression

web.sendGzip($ctx, status, body) answers with the body gzip-compressed when the client’s Accept-Encoding names gzip, otherwise plain. It always sets Vary: Accept-Encoding (so caches don’t cross the wires) and, when compressing, Content-Encoding: gzip. Set the Content-Type yourself first. Worth it for large text / JSON / HTML; skip it for already-compressed payloads (images, archives).

func report(ctx as web.Context) {
    web.setHeader($ctx, "Content-Type", "application/json");
    web.sendGzip($ctx, 200, json.encode($bigDocument));
}

Serving

Call
web.run($app, addr)Listen on addr and serve forever (blocks; interrupt to stop).
web.serveOn($app, srv)Serve on an already-listening httpd.Server you hold - so you can shut it down (or serve from a spawn).

web.run is the common path. Use web.serveOn when you want the server handle, e.g. to shut down from another task:

def srv as httpd.Server init httpd.listen(":8080");
def worker as task of null init spawn { web.serveOn($app, $srv); };
# ... later ...
httpd.shutdown($srv);
task.wait($worker);

Running with jennifer serve

jennifer serve app.j runs a web app; --watch restarts it whenever the entry file changes - a Hugo-style edit / reload loop:

jennifer serve app.j            # run the app
jennifer serve app.j --watch    # reload on change

--watch is not web-specific: it re-runs any program on every change to the entry file, so it doubles as an autorun / edit-and-rerun loop for plain scripts too. See the serve command.

See also

  • httpd - the server engine web is built on.
  • http - the HTTP client module (talk to other servers).
  • json - encode response bodies for web.sendJson.
  • rest, session, ratelimit - companions for a fuller serving stack.