santoku
santoku

santoku

A Lua framework for building applications end to end

Lua 5.1 from the build system to the server to the browser. Every example on this site runs in your browser. Edit one and run it.


santoku targets Lua 5.1, because 5.1 is the version its platforms actually share. LuaJIT implements 5.1 and OpenResty embeds LuaJIT, so server code, client code and the build harness all run the same language. Compiled to WebAssembly, the 5.1 core is smaller to embed, and its single number type sidesteps the integer and float split introduced in 5.3, which keeps numeric values crossing the JavaScript boundary simple. Support for newer versions is meant to come from compatibility shims layered over the 5.1 core rather than version branches in the tree; contributions and design ideas for that layer are welcome.

santoku-resty

View on LuaRocks

OpenResty glue: santoku-flavored wrappers over lua-resty HTTP clients, RS256 and ES256 JWTs over santoku-jwt, and the OpenResty WebSocket backend

santoku-resty is the OpenResty glue layer: thin wrappers over the upstream lua-resty rocks (HTTP client, OpenSSL, WebSocket) that add santoku error handling and a smaller, uniform surface. Its JWT module runs santoku-jwt's core over santoku.resty.jwt.openssl, the OpenSSL backend for RS256 and ES256; the santoku-jwt tab covers that core. Every module here calls the ngx API and the cosocket-based upstream rocks, so it only runs inside an nginx worker: all examples on this page are display-only. nginx confs templated by santoku-mustache route requests into Lua handlers that use these modules for outbound HTTP and token checks.

santoku.resty.http: one-call requests

request wraps resty.http's request_uri in a single call: pass url, method, body, headers, and ssl_verify in one options table and get back a plain { status, headers, body } table. On transport failure it raises through santoku.error instead of returning nil plus a message.

local http = require("santoku.resty.http") local res = http.request({ url = "https://api.example.com/things", method = "POST", body = '{"name":"widget"}', headers = { ["Content-Type"] = "application/json" }, ssl_verify = true, }) print(res.status) print(res.headers["Content-Type"]) print(res.body)
Illustrative: runs outside the browser.

santoku.resty.http: configure hook and structured errors

The configure option receives the underlying resty.http client before the request fires, for timeouts and other client-level settings. Because failures raise, err.pcall is the idiom for handling them without crashing the handler.

local http = require("santoku.resty.http") local err = require("santoku.error") local ok, e = err.pcall(http.request, { url = "https://internal.example.com:8443/health", configure = function (client) client:set_timeout(5000) end, }) if not ok then ngx.log(ngx.ERR, "health check failed: ", e) ngx.status = 502 return end ngx.say("upstream healthy")
Illustrative: runs outside the browser.

santoku.resty.socket: fetch without raising

The second HTTP client never raises: fetch returns ok, response where ok reflects a 2xx status. The response normalizes header keys to lowercase, exposes the body as a function, and repeats ok as a field. A transport error yields { status = 0, ok = false, error = e } with a body function that returns nil.

local socket = require("santoku.resty.socket") local ok, res = socket.fetch("https://api.example.com/items", { method = "GET", headers = { ["Accept"] = "application/json" }, }) print(ok, res.status) print(res.headers["content-type"]) if ok then print(res.body()) else print("failed:", res.error or res.status) end
Illustrative: runs outside the browser.

santoku.resty.socket: cancelable requests and sleep

request defers the work into a { cancel, await } pair. cancel before await short-circuits to { status = 0, ok = false, canceled = true }; cancel while in flight closes the underlying client. sleep wraps ngx.sleep, taking milliseconds, and yields the current request's coroutine instead of blocking the worker.

local socket = require("santoku.resty.socket") local req = socket.request("https://api.example.com/big-report", { method = "GET", }) req.cancel() local ok, res = req.await() print(ok, res.status, res.canceled) socket.sleep(500) local ok2, res2 = socket.fetch("https://api.example.com/big-report") print(ok2, res2.status)
Illustrative: runs outside the browser.

A payments client

santoku.http takes a backend with socket's fetch and request functions, so handing it santoku.resty.socket gives a server-side client with retries and jittered backoff.

local json = require("cjson") local str = require("santoku.string") local http = require("santoku.http")(require("santoku.resty.socket")) local function stripe_request (method, path, params, key) local opts = { method = method, headers = { ["Authorization"] = "Bearer " .. key, ["Content-Type"] = "application/x-www-form-urlencoded", }, } if params then opts.body = str.to_formdata(params) end local ok, res = http.fetch("https://api.stripe.com" .. path, opts) local body = res.body() if not ok then return nil, "stripe error " .. res.status .. ": " .. (body or "") end return json.decode(body) end local session = stripe_request("POST", "/v1/checkout/sessions", { mode = "subscription", ["line_items[0][quantity]"] = "1", }, secret_key) print(session and session.url)
Illustrative: runs outside the browser.

santoku.resty.jwt: parse and verify

parse and verify take the compact JWT (header.payload.signature) as a client sends it; pass the token alone, never the raw Authorization header. parse splits it without verification into header, payload, signing_input and signature. verify(token, get_jwk, get_time, claim_spec) accepts RS256 and ES256, resolves the JWK by the token's kid through your callback, and checks the signature with OpenSSL. The default claim spec rejects a token whose exp is missing or not after get_time(); pass jwt.claims or your own claim_spec as the fourth argument to change that. Failures return false plus a reason: Malformed token, Unsupported alg, No kid, No jwk for kid, Unsupported key type, Key alg mismatch, Bad signature, or the failing claim's message (expired, not yet valid, wrong audience, wrong issuer).

local jwt = require("santoku.resty.jwt") local parsed = jwt.parse(token) print(parsed.header.kid) local ok, data = jwt.verify(token, jwt.keyset(jwks_doc), ngx.time) if ok then print("sub:", data.payload.sub) else print("rejected:", data) end
Illustrative: runs outside the browser.

santoku.resty.jwt: keys, signing and a JWKS

keypair(bits) generates an RSA key (2048 bits by default) and returns the private key as PEM plus its public JWK. pem_to_jwk(pem) builds that JWK from any RSA PEM, with kty, alg RS256, use sig, base64url n and e, and the RFC 7638 thumbprint as kid. sign(claims, pem, kid) returns an RS256 compact JWT. jwks(jwk, ...) wraps public JWKs into the { keys } document you serve at a well-known path, and keyset(doc) turns a JWKS document into the kid lookup verify takes. Keep the private PEM out of the repo: load it from a secret at init time.

local cjson = require("cjson") local jwt = require("santoku.resty.jwt") local pem, jwk = jwt.keypair() local token = jwt.sign({ sub = "u1", aud = "app", exp = ngx.time() + 3600 }, pem, jwk.kid) local doc = jwt.jwks(jwk) ngx.say(cjson.encode(doc)) local ok = jwt.verify(token, jwt.keyset(doc), ngx.time) ngx.say(ok)
Illustrative: runs outside the browser.

santoku.resty.jwt: a remote JWKS and claim checks

remote_jwks(url, opts) returns a kid lookup backed by a JWKS fetched over santoku.resty.http and cached per worker. It refetches after opts.ttl seconds (3600) and on an unknown kid at most once per opts.min_interval seconds (60), and keeps the last good set when a fetch fails or returns a document without a keys array. A failed fetch is retried after min_interval, not ttl. Requests that arrive while a fetch is in flight wait for it, up to opts.wait seconds (10), then read the new set. opts.time replaces ngx.now as the clock. A *_by_lua_file chunk runs again on every request, so create the lookup in a module the handler requires; created in the chunk itself, each request starts with an empty cache. opts.fetch(url) replaces the fetch and returns status and body, for example through a proxied location with ngx.location.capture. A cosocket fetch needs a resolver directive and lua_ssl_trusted_certificate in the nginx conf. claims({ aud, iss, time }) builds a claim spec that checks exp against time() (ngx.time by default), aud as a string or array member, and iss. This is the Cloudflare Access pattern: the token arrives in the Cf-Access-Jwt-Assertion header and the keys at the team's certs URL.

local jwt = require("santoku.resty.jwt") local certs = jwt.remote_jwks("https://myteam.cloudflareaccess.com/cdn-cgi/access/certs") local spec = jwt.claims({ aud = "my-access-app-aud-tag", iss = "https://myteam.cloudflareaccess.com", }) local token = ngx.var.http_cf_access_jwt_assertion local ok, data = jwt.verify(token, certs, nil, spec) if not ok then return ngx.exit(ngx.HTTP_FORBIDDEN) end ngx.say(data.payload.email)
Illustrative: runs outside the browser.

santoku.resty.jwt: JWK to PEM

jwk_to_pem takes an RSA or P-256 public JWK, loads it with lua-resty-openssl's JWK reader and writes it out as a PEM public key string. The work happens in the OpenSSL linked into nginx, so the rock has no C code and needs no OpenSSL headers to build. verify reads JWKs directly; this is for callers that cache PEMs or feed other OpenSSL APIs.

local jwt = require("santoku.resty.jwt") local pkey = require("resty.openssl.pkey") local pem = jwt.jwk_to_pem({ kty = "RSA", n = jwk.n, e = jwk.e, }) print(pem) local key = pkey.new(pem) print(key:get_key_type().sn, key:is_private())
Illustrative: runs outside the browser.

A bearer-auth guard for handlers

The verify signature composes into a small middleware: read the Authorization header from ngx, strip the Bearer prefix, verify against a JWKS document, extract the subject with santoku.table, and only then run the handler body.

local str = require("santoku.string") local tbl = require("santoku.table") local jwt = require("santoku.resty.jwt") local keys = jwt.keyset(require("app.jwks")) local function with_sub (callback) local header = ngx.req.get_headers()["authorization"] local auth = header and str.match(header, "^Bearer%s+(%S+)$") if not auth then ngx.status = 401 return end local ok, data = jwt.verify(auth, keys, ngx.time) if not ok and data == "expired" then ngx.status = 401 ngx.say(data) return elseif not ok then ngx.status = 401 return end local sub = tbl.get(data, { "payload", "sub" }) if not sub then ngx.status = 401 return end return callback(sub) end with_sub(function (sub) ngx.say("hello ", sub) end)
Illustrative: runs outside the browser.

santoku.resty.stream: a TLS stream on a cosocket

connect(opts, done) is santoku.socket.stream's contract inside an nginx worker, so a protocol client such as santoku-imap runs on either. It opens an ngx.socket.tcp to opts.host and opts.port, runs the TLS handshake with opts.sslname or the host as SNI and verification on (opts.verify = false skips it, opts.tls = false skips TLS), then calls done(true, conn) or done(false, reason). A light thread reads in 8 KiB chunks and hands each to opts.data(chunk); opts.closed(reason) fires once when the peer closes or a read or write fails. conn.write(data) returns true, or false and the reason; conn.close() shuts the socket; conn.thread is the reader thread. Connect, send and read timeouts default to 30000 ms (connect_timeout_ms, send_timeout_ms, read_timeout_ms). Verification needs lua_ssl_trusted_certificate, and a hostname needs a resolver, in the nginx conf.

local stream = require("santoku.resty.stream") stream.connect({ host = "imap.example.com", port = 993, data = function (chunk) ngx.log(ngx.INFO, "read ", #chunk, " bytes") end, closed = function (e) ngx.log(ngx.INFO, "closed: ", tostring(e)) end, }, function (ok, conn) if not ok then return ngx.log(ngx.ERR, "connect failed: ", conn) end conn.write("A1 CAPABILITY\r\n") ngx.sleep(1) conn.close() end)
Illustrative: runs outside the browser.

santoku.resty.tcp: santoku-mysql's cosocket transport

connect(opts) is santoku.socket.tcp's contract over ngx.socket.tcp: host and port, or path for a unix socket, and timeout_ms (30000) for connect, send and read. It returns a transport with send, receive(n), starttls and close, or nil and the error. opts.pool names a keepalive pool of opts.pool_size connections, t.reused counts how often nginx handed this one out before, and keepalive(idle_ms) returns it to the pool in place of close. starttls verifies against lua_ssl_trusted_certificate, raises if given cafile or capath, and checks an IP host against the certificate's IP entries. semaphore(n) wraps ngx.semaphore with wait(ms) and post(). pooled = true tells santoku-mysql to borrow a connection per call. Like every cosocket, a transport serves only the request that opened it.

local tcp = require("santoku.resty.tcp") local t, e = tcp.connect({ host = "10.0.0.5", port = 6379, pool = "cache", pool_size = 8 }) if not t then return ngx.log(ngx.ERR, "connect failed: ", e) end t.send("PING\r\n") ngx.say(t.receive(7) == "+PONG\r\n", " reused ", t.reused) t.keepalive(60000)
Illustrative: runs outside the browser.

santoku.resty.websocket.client

The module is a constructor: connect(url, nopts, copts) passes nopts to resty.websocket.client's new and copts to connect, then returns a three-method object. send transmits binary frames. receive loops internally: it answers pings with pongs, skips pongs, joins a text or binary frame with its continuation frames into one message, and returns nil after answering a close frame. Any other frame type, and any frame-level failure, raises through santoku.error.

local connect = require("santoku.resty.websocket.client") local ws = connect("wss://feed.example.com/socket", { timeout = 5000, }) ws.send('{"subscribe":"updates"}') local msg = ws.receive() while msg do print("frame:", msg) msg = ws.receive() end ws.close()
Illustrative: runs outside the browser.

santoku.resty.websocket.server

The server side takes one options table for resty.websocket.server's new and returns the same three methods. Its receive is the client's: whole messages, pings answered, pongs skipped. close sends a close frame to the peer.

local accept = require("santoku.resty.websocket.server") local ws = accept({ timeout = 60000, max_payload_len = 65535, }) while true do local msg = ws.receive() if not msg then break end ws.send("echo: " .. msg) end ws.close()
Illustrative: runs outside the browser.

Templated nginx confs and web handlers

The nginx conf is typically a santoku-template file rendered at build time, mapping each API location to content_by_lua_file with a bundled web module. Those handlers take the inbound side with the raw ngx API and use this library for the outbound side, like the Stripe portal handler below.

local auth = require("tasks.web.auth") local stripe = require("tasks.stripe") local psub, user = auth.signed_header("portal") auth.require_token(psub) if not user.stripe_customer_id or user.stripe_customer_id == "none" then return auth.fail(400, "no_subscription") end local portal_url, e = stripe.create_portal_session( user.stripe_customer_id, return_url) if not portal_url then return auth.fail(500, "portal_failed", { detail = e }) end ngx.header.content_type = "text/plain" ngx.say(portal_url)
Illustrative: runs outside the browser.