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-socket

View on LuaRocks

HTTP(S) client: blocking fetch, cancelable request/await handle, and millisecond sleep over LuaSocket and LuaSec

santoku-socket is the framework's native HTTP(S) client: a thin wrapper over LuaSocket and LuaSec (ssl.https plus ltn12) with a small, uniform request and response format. The surface is small: fetch for a blocking request, request for a cancelable handle whose await runs it, and a millisecond sleep built on santoku-system. Those same names are what santoku-http expects of a backend, so plugging this module into that front end adds retry, hooks, and query building with no glue, and identical client code runs over this module's browser and OpenResty siblings. It needs native TLS sockets, so it runs in servers, CLIs, and scripts rather than in this page: the examples are shown for reading, except the response model, which is plain Lua and runs live. The examples run in order: fetch, the response contract, failure paths, request and cancel, sleep, composition with santoku-http, the raw TLS stream driver underneath it all, the WebSocket client built on that driver, the TCP transport santoku-mysql runs over, and a loopback HTTP listener for browser callbacks.

fetch: a simple GET

A blocking GET returns ok (true for 2xx) and a response table; body is a function returning the full text. The whole ssl.https call sits inside a pcall, so fetch itself never raises on network trouble.

local socket = require("santoku.socket") local ok, resp = socket.fetch("https://example.com/") print(ok, resp.status) print(resp.headers["content-type"]) return #resp.body()
Illustrative: runs outside the browser.

The response fields

Every completed response carries status, ok, headers, and body. Header keys are lowercased on the way in, and resp.ok always equals the first return.

local socket = require("santoku.socket") local ok, resp = socket.fetch("https://example.com/") print(resp.status) print(resp.ok == ok) for k, v in pairs(resp.headers) do print(k, v) end return resp.body()
Illustrative: runs outside the browser.

How a response is built, modeled live

The same steps the module takes after ssl.https returns: concatenate the sink chunks into one body, lowercase every header key, compute ok from the 2xx range, and capture the body in a closure so it reads repeatedly. The construction is plain Lua, so this one runs here.

local arr = require("santoku.array") local str = require("santoku.string") local chunks = { "{\"pong\"", ":true}" } local raw_headers = { ["Content-Type"] = "application/json", ["X-Request-Id"] = "abc123", } local body = arr.concat(chunks) local headers = {} for k, v in pairs(raw_headers) do headers[str.lower(k)] = v end local status = 200 local is2xx = status >= 200 and status < 300 local resp = { status = status, ok = is2xx, headers = headers, body = function () return body end, } print(resp.status, resp.ok) print(resp.headers["content-type"], resp.headers["x-request-id"]) print(resp.body()) print(resp.body() == resp.body()) return resp.body()

POST with a body

Pass method, headers, and body in opts. When a body is present and no content-length header is given, one is derived from the body's byte length.

local socket = require("santoku.socket") local ok, resp = socket.fetch("https://api.example.com/echo", { method = "POST", headers = { ["content-type"] = "application/json" }, body = "{\"hello\":\"world\"}", }) print(ok, resp.status) return resp.body()
Illustrative: runs outside the browser.

Methods and headers

Any method string passes straight through to the transport, and an explicit content-length is respected rather than overwritten. Auth is just another header.

local socket = require("santoku.socket") local token = "..." local ok, resp = socket.fetch("https://api.example.com/items/42", { method = "PUT", headers = { ["authorization"] = "Bearer " .. token, ["content-type"] = "text/plain", ["content-length"] = "5", }, body = "hello", }) print(ok, resp.status) return resp.status
Illustrative: runs outside the browser.

Non-2xx responses

A request that completes with a non-2xx status returns false but still carries the real status, headers, and body, so error pages remain readable.

local socket = require("santoku.socket") local ok, resp = socket.fetch("https://example.com/missing") print(ok, resp.ok, resp.status) return resp.body()
Illustrative: runs outside the browser.

TLS verification

An https fetch verifies the server certificate against the host's CA bundle (the first of santoku.socket.stream.ca_paths that exists, or opts.cafile and opts.capath) and checks the hostname against the certificate's names, the same path santoku.socket.stream uses for raw TLS connections. A failure in either check lands in resp.error with status 0 and no body. opts.verify = false skips both, and opts.sslname names a host to verify and send as SNI when it differs from the URL's.

local socket = require("santoku.socket") local ok, resp = socket.fetch("https://self-signed.badssl.com/") print(ok, resp.status, resp.error) local ok2 = socket.fetch("https://self-signed.badssl.com/", { verify = false }) print(ok2) local ok3 = socket.fetch("https://10.0.0.5/", { cafile = "/etc/myca/root.pem", sslname = "api.internal", }) return ok3
Illustrative: runs outside the browser.

Transport failures

Failures before any status arrives take one of two paths: a raised transport error is caught by the pcall and lands in resp.error, and a nil return from the transport puts its message there instead. Both yield status 0, empty headers, and a body() that returns nil rather than raising.

local socket = require("santoku.socket") local ok, resp = socket.fetch("https://localhost:1/down") print(ok, resp.status) print(resp.error) return resp.body() == nil
Illustrative: runs outside the browser.

Saving a response to disk

body() hands back the whole text, so persisting a download is one santoku.fs call.

local socket = require("santoku.socket") local fs = require("santoku.fs") local ok, resp = socket.fetch("https://example.com/data.json") if ok then fs.writefile("data.json", resp.body()) end print(ok, resp.status) return resp.headers["content-length"]
Illustrative: runs outside the browser.

request: a cancelable handle

request defers the work: nothing happens until await, which runs the fetch and returns the same ok, resp pair as fetch.

local socket = require("santoku.socket") local req = socket.request("https://example.com/") local ok, resp = req.await() print(ok, resp.status) return resp.body()
Illustrative: runs outside the browser.

cancel before and after the fetch

Canceling before await short-circuits without issuing a request. await also re-checks the flag after the fetch returns, so a cancel raced in from a hook discards the response and yields the canceled sentinel. That sentinel is { status = 0, headers = {}, ok = false, canceled = true } with no body function, so check canceled before reading.

local socket = require("santoku.socket") local job = socket.request("https://example.com/slow") job.cancel() local ok, resp = job.await() print(ok, resp.status, resp.canceled) return resp.canceled
Illustrative: runs outside the browser.

sleep: milliseconds

sleep takes milliseconds and delegates to santoku.system's second-based sleep, whose C side spends the whole seconds in sleep() and the remainder in usleep(), so fractional pauses like 250ms and 1.5s both work.

local socket = require("santoku.socket") socket.sleep(250) socket.sleep(1500) return true
Illustrative: runs outside the browser.

A REST wrapper in one function

One function turns method, path, and params into a bearer-authenticated form-encoded request and returns a decoded JSON table or an error string. Because body() returns nil rather than raising on transport failure, the error path can use resp.error or the body without extra guards.

local socket = require("santoku.socket") local str = require("santoku.string") local cjson = require("cjson") local key = "..." local function stripe (method, path, params) 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, resp = socket.fetch("https://api.stripe.com" .. path, opts) local body = resp.body() if not ok then return nil, "request failed: " .. (resp.error or body or "unknown") end return cjson.decode(body) end local session, err = stripe("POST", "/v1/checkout/sessions", { mode = "subscription", }) return session or err
Illustrative: runs outside the browser.

As the backend of santoku-http

santoku-http expects a backend with fetch, request, and sleep, and prefers request when present, so cancellation flows through. One line adds query params via to_query, request and response hooks, and retry with jittered backoff: by default 3 retries starting at 1000ms with a 3x multiplier, triggered on status 0, 429, 502, 503, and 504. Retry and backoff belong there, so don't hand-roll them over fetch. To bound a whole retry sequence, cancel the request: santoku-http checks for cancellation after every backoff sleep.

local http = require("santoku.http")(require("santoku.socket")) local ok, resp = http.get("https://api.example.com/search", { params = { q = "lua" }, }) print(ok, resp.status) local req = http.fetch("https://api.example.com/slow", { cancelable = true }) req.cancel() local ok2, r2 = req.await() print(ok2, r2.canceled) return r2.canceled
Illustrative: runs outside the browser.

One contract, three transports

Because the response fields are shared, the same application code runs over three interchangeable backends: this module in native scripts, santoku.web.socket in the browser, and santoku.resty.socket under OpenResty.

local native = require("santoku.http")(require("santoku.socket")) local browser = require("santoku.http")(require("santoku.web.socket")) local server = require("santoku.http")(require("santoku.resty.socket")) local function ping (http) local ok, resp = http.get("https://example.com/ping") return ok and resp.body() end return ping
Illustrative: runs outside the browser.

santoku.socket.stream: the raw TLS stream driver

Protocols that are not request and response need bytes rather than messages. santoku.socket.stream is that layer: connect(opts, done) dials host and port, wraps the socket in TLS unless tls = false, and hands done(ok, conn) a connection with write, close and step. Inbound data is pushed, not pulled: every chunk the socket produces goes to the data callback in whatever fragmentation the network gave it, and reassembly is the protocol's job. Because LuaSocket blocks and has no event loop, this driver also exposes step(ms), one bounded read delivered through the same data callback; it returns true, "timeout" when the window passed with nothing to read, and false plus a reason once the connection is gone. connect_timeout_ms defaults to 30000, and sslname overrides the SNI name when it differs from host.

local stream = require("santoku.socket.stream") stream.connect({ host = "imap.example.com", port = 993, data = function (chunk) print("read", #chunk, "bytes") end, closed = function (e) print("closed:", e) end, }, function (ok, conn) if not ok then return print("connect failed:", conn) end print("verified:", conn.tls.verified) conn.write("A1 CAPABILITY\r\n") for _ = 1, 10 do conn.step(200) end conn.close() end) return true
Illustrative: runs outside the browser.

santoku.socket.websocket: a WebSocket client

connect(url, opts, done) opens a ws:// or wss:// connection over the stream driver, runs the RFC 6455 handshake and hands done(ok, conn) the connection or a reason. Messages are pushed: opts.message(data, kind) receives each reassembled message with kind "text" or "binary", and opts.ping, opts.pong and opts.closed(code, reason) report control frames. conn.send(data[, binary]) sends text unless binary is true and refuses text that is not valid UTF-8; conn.ping(data), conn.close([code, reason, ms]) (1000 and a one-second wait by default), conn.step(ms) for one bounded read, and conn.wait(fn, ms) steps until fn() is true or the time runs out, returning false, "timeout". Nothing reads a conn's socket except its own step and wait, so with two clients in one process, a message for the second waits in its socket until you call wait or step on that conn. Other options: connect_timeout_ms (30000), max_message (64 MiB), frame_max to fragment large sends, protocols, headers, and verify, cafile, capath and sslname as for the stream driver. It has driven Chrome's DevTools protocol end to end, a 299 KB screenshot message included.

local ws = require("santoku.socket.websocket") local reply ws.connect("wss://echo.example.com/socket", { message = function (data, kind) reply = data end, closed = function (code, reason) print("closed:", code, reason) end, }, function (ok, conn) if not ok then return print("connect failed:", conn) end conn.send("hello") print(conn.wait(function () return reply ~= nil end, 5000), reply) conn.close() end) return true
Illustrative: runs outside the browser.

santoku.socket.tcp: a byte transport for santoku-mysql

connect(opts) opens a TCP connection to opts.host and opts.port, or a unix socket at opts.path, and returns a transport, or nil and the error. opts.timeout_ms (30000) bounds every connect, send and receive. send(data) writes all of data and returns true, or nil and the error. receive(n) blocks until it has n bytes. starttls(opts) upgrades the connection in place with santoku.socket.stream's verification (verify, cafile, capath, sslname) and sets t.tls. idle() returns true when no unread bytes wait on the socket, and close() closes it. santoku-mysql takes this module as its native transport, and santoku-resty's santoku.resty.tcp is the same contract over cosockets.

local tcp = require("santoku.socket.tcp") local err = require("santoku.error") local t = err.assert(tcp.connect({ host = "127.0.0.1", port = 6379, timeout_ms = 2000 })) err.assert(t.send("PING\r\n")) print(t.receive(7) == "+PONG\r\n", t.idle()) t.close() print(tcp.connect({ host = "127.0.0.1", port = 1, timeout_ms = 500 }))
Illustrative: runs outside the browser.

santoku.socket.server: a loopback HTTP listener

listen(opts) binds opts.host (127.0.0.1) on opts.port, 0 by default for an ephemeral port, and returns a server with host, port, url, serve and close. It suits a CLI that needs a browser to call it back, such as an OAuth redirect or a page that posts a token. serve(handler, { timeout = ms }) accepts one connection at a time and calls handler(req) with method, path, target, query (decoded), headers (lowercased) and body (read to Content-Length, at most opts.max_body bytes, 1 MiB by default). The handler returns a response table { status, headers, body } and, to stop, a second value; serve then returns true and that value. At the timeout serve returns false and "timeout". Every response closes its connection. A malformed request gets 400, an oversized body 413, and a slow client 408, without reaching the handler. A handler error answers 500 and raises. No TLS, no keepalive and no chunked bodies.

local server = require("santoku.socket.server") local srv = server.listen() print("open", srv.url) local ok, token = srv.serve(function (req) if req.method == "GET" and req.path == "/" then return { status = 200, headers = { ["Content-Type"] = "text/html" }, body = "<form method=post action=/done><input name=t><button>Send</button></form>" } elseif req.method == "POST" and req.path == "/done" then return { status = 200, body = "You can close this tab." }, req.body end return { status = 404 } end, { timeout = 600000 }) srv.close() print(ok, token)
Illustrative: runs outside the browser.

Verification is on, and the hostname is checked here

luasec verifies the certificate chain and nothing else, so this driver does the rest. It takes the CA bundle from SSL_CERT_FILE, or the directory from SSL_CERT_DIR, then probes the common Linux and macOS paths, and last uses the bundle or directory OpenSSL was built to look in. It verifies the chain against it and matches the target name against the certificate's subjectAltName dNSName entries, honouring a single leading wildcard label. A certificate with no subjectAltName fails, since RFC 6125 retires commonName matching. A mismatch closes the connection and reports which names the certificate actually carries. No bundle found is a hard error rather than a silent downgrade: set SSL_CERT_FILE, pass cafile or capath, or verify = false to opt out deliberately. conn.tls reports the outcome, carrying verified, the bundle used, and the names matched against. stream.ca_paths is the probe list and can be extended before connecting.

local stream = require("santoku.socket.stream") stream.connect({ host = "imap.example.com", port = 993, cafile = "/etc/ssl/certs/ca-certificates.crt", data = function (chunk) print(#chunk) end, }, function (ok, conn) print(ok, ok and conn.tls.verified, ok and conn.tls.names[1]) end) stream.connect({ host = "192.0.2.10", port = 993, verify = false, data = function () end, }, function (ok, conn) print("unverified:", ok, conn.tls.verified) end) return true
Illustrative: runs outside the browser.

One driver contract, every runtime

The driver is injected, so a protocol written against this contract runs unchanged wherever a driver exists: santoku.socket.stream over LuaSocket and LuaSec, santoku.web.stream over node tls under wasm, and santoku.resty.stream over ngx cosockets. santoku-imap is the consumer to read: it takes a driver and detects whether step is present, pumping internally on a pull driver so that completion is synchronous there and event-driven on a push driver, with the same callback API either way.

local imap = require("santoku.imap")(require("santoku.socket.stream")) imap.connect({ host = "imap.example.com", port = 993 }, function (ok, client) if not ok then return print("connect failed:", client) end client.login("[email protected]", "app-password", function (okl) print("login:", okl) client.logout(function () end) end) end) return true
Illustrative: runs outside the browser.