From 6c244009c1722319fc5dfd7d9a7cdb773d5f3b1c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 12:38:36 +0000 Subject: [PATCH 1/2] Add OpenResty web app example (Shen backend, typed validation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A complete guestbook web app whose server logic is written in Shen and runs on OpenResty (nginx + LuaJIT). Demonstrates the architecture for a real shen-lua web app: - validate.shen: typed request validators, loaded under (tc +) so a type error aborts startup before any request is served - app.shen: a Shen router (dispatch + storage orchestration), loaded untyped - app.lua: the glue — boots Shen once per worker, marshals JSON <-> the tagged `val` shape, exposes the content handler; degrades to a bundled JSON shim off-nginx so the example is testable under plain luajit - nginx.conf: boot-per-worker via init_worker_by_lua, lua_shared_dict storage - public/index.html: plain HTML+fetch front end (the ShenScript slot) - selftest.lua: drives the full app under luajit, no nginx required (8 cases) Registered in both README example tables. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014PiuPBGphzddJoh5qLjogD --- .gitignore | 1 + README.md | 1 + examples/README.md | 5 +- examples/openresty/README.md | 130 +++++++++++++++++++++++ examples/openresty/app.lua | 145 ++++++++++++++++++++++++++ examples/openresty/app.shen | 55 ++++++++++ examples/openresty/json_shim.lua | 148 +++++++++++++++++++++++++++ examples/openresty/nginx.conf | 73 +++++++++++++ examples/openresty/public/index.html | 88 ++++++++++++++++ examples/openresty/selftest.lua | 51 +++++++++ examples/openresty/validate.shen | 84 +++++++++++++++ 11 files changed, 779 insertions(+), 2 deletions(-) create mode 100644 examples/openresty/README.md create mode 100644 examples/openresty/app.lua create mode 100644 examples/openresty/app.shen create mode 100644 examples/openresty/json_shim.lua create mode 100644 examples/openresty/nginx.conf create mode 100644 examples/openresty/public/index.html create mode 100644 examples/openresty/selftest.lua create mode 100644 examples/openresty/validate.shen diff --git a/.gitignore b/.gitignore index 17587a8..c41455a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ build/shen-bundle.lua .fasl-test/ luacov.stats.out luacov.report.out +examples/openresty/logs/ diff --git a/README.md b/README.md index f5faf70..f03d65e 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,7 @@ exhaustively at the top of [`lua_interop.lua`](lua_interop.lua). | [`examples/hello_embed.lua`](examples/hello_embed.lua) | the smallest useful embedding: boot, define a typed function, call it both ways (~25 lines) | | [`examples/family.shen`](examples/family.shen) | Shen Prolog in twenty lines: facts, rules, queries via `bin/shen` | | [`examples/config_check.lua`](examples/config_check.lua) | the showcase: Shen datatypes + rules as a **typed validation layer** for nested Lua config tables — the typechecker rejects buggy rules at load time ([walkthrough](examples/README.md)) | +| [`examples/openresty/`](examples/openresty/) | a **complete web app in Shen on OpenResty** (nginx + LuaJIT): typed request validators + a Shen router behind a JSON API, with a plain-HTML front end. Runs standalone (`luajit examples/openresty/selftest.lua`) or under `openresty` ([README](examples/openresty/README.md)) | ## Certification / Testing diff --git a/examples/README.md b/examples/README.md index d8e3a7b..17dd171 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,13 +1,14 @@ # Examples -Three examples, smallest first (all run with plain `luajit`/`bin/shen`, -no external dependencies, no network): +Four examples, smallest first (the first three run with plain +`luajit`/`bin/shen`, no external dependencies, no network): | | | |---|---| | [`hello_embed.lua`](hello_embed.lua) | the smallest useful embedding: boot, define a typed Shen function, call it from Lua, pass lists both ways. `luajit examples/hello_embed.lua` | | [`family.shen`](family.shen) | Shen Prolog in twenty lines — facts, rules, yes/no and binding queries. `bin/shen examples/family.shen` | | [`config_check.lua`](config_check.lua) | the showcase, walked through below. `luajit examples/config_check.lua` | +| [`openresty/`](openresty/) | a complete web app — typed Shen validators + a Shen router on OpenResty (nginx + LuaJIT), with a plain-HTML front end. Runs standalone via `luajit examples/openresty/selftest.lua`; see [its README](openresty/README.md) to serve it. | --- diff --git a/examples/openresty/README.md b/examples/openresty/README.md new file mode 100644 index 0000000..324e27a --- /dev/null +++ b/examples/openresty/README.md @@ -0,0 +1,130 @@ +# A web app in Shen, on OpenResty + +A small but complete web app whose **server-side logic is written in Shen** and +runs on [OpenResty](https://openresty.org) (nginx + LuaJIT). It's a guestbook: +a JSON API plus a one-file front end. The point isn't the guestbook — it's the +shape of a real Shen web app and the handful of rules that make it work. + +OpenResty is nginx embedding **LuaJIT 2.1**, which is exactly shen-lua's primary +host, so the backend can literally be Shen with a thin Lua glue layer. + +``` +examples/openresty/ + validate.shen the TYPED core — field rules, proved sound at load time + app.shen the router — dispatch + storage orchestration (untyped shell) + app.lua the glue — boots Shen, marshals JSON <-> Shen, the handler + nginx.conf OpenResty config: boot once per worker, two locations + public/ the front end (plain HTML+fetch; the ShenScript slot) + selftest.lua drives the whole app under plain luajit, no nginx needed + json_shim.lua a tiny JSON codec used only off-nginx (OpenResty has cjson) +``` + +## Try it without nginx + +The Shen code and all of its routing/validation logic run under plain LuaJIT: + +```sh +luajit examples/openresty/selftest.lua +``` + +``` +== guestbook API (in-memory store) == +GET empty list -> 200 {"messages":[]} +POST valid -> 201 {"ok":true} +GET list (2 rows) -> 200 {"messages":[{"name":"ada",...}]} +POST missing name -> 400 {"errors":["name: is required"]} +POST blank message -> 400 {"errors":["message: must be 1..280 characters"]} +POST not an object -> 400 {"errors":["body: must be a JSON object"]} +unknown route -> 404 -> 404 {"error":"not found"} +OK — all cases passed +``` + +## Run it under OpenResty + +With `openresty` on your PATH, from the repo root: + +```sh +mkdir -p examples/openresty/logs +openresty -p "$PWD/examples/openresty" -c nginx.conf +``` + +Then open and sign the guestbook. The API is also +reachable directly: + +```sh +curl -s localhost:8080/api/messages +curl -s localhost:8080/api/messages -d '{"name":"ada","message":"hi"}' +curl -s localhost:8080/api/messages -d '{"message":"no name"}' # -> 400 + typed errors +``` + +(`-p` sets the nginx prefix to this directory, so `logs/` and `public/` resolve +here; `init_by_lua` derives the repo root from the prefix to put shen-lua on +`package.path`.) + +## How it fits together + +``` +browser ──HTTP──> nginx location /api/ ──> app.lua: handle() + │ JSON -> Shen `val` + ▼ + (route Method Path Body) app.shen [untyped shell] + │ └─ (validate-message Body) validate.shen [typed core] + │ └─ (lua.call "host.store_*") -> lua_shared_dict + ▼ + [Status BodyVal] + │ Shen `val` -> JSON + ▼ + HTTP response +``` + +### Boot once per worker — the one rule that matters + +Booting the Shen kernel (and typechecking `validate.shen`) costs real time — +tens of ms warm from the bytecode cache, ~1 s cold. `nginx.conf` does it in +`init_worker_by_lua`, so it happens **once per worker, never per request**. A +warm worker handles requests with no per-call boot cost. + +### Typed core, untyped shell + +- **`validate.shen` loads under `(tc +)`.** Its field rules are checked by + Shen's sequent-calculus typechecker *at load time* — a type error in a + validator aborts startup, before the server ever takes a request (the same + guarantee as [`examples/config_rules.shen`](../config_rules.shen)). +- **`app.shen` loads under `(tc -)`.** Routing and storage are effectful (they + touch nginx and a shared dict), so this half is untyped on purpose. It still + calls the typed validators directly — both files load into one environment. + +### Crossing the Lua boundary + +`app.lua` marshals a cjson-decoded request body into the tagged `val` shape +(`"x"` → `[s "x"]`, `8080` → `[n 8080]`, objects → `[obj [[k v] ...]]`) that the +Shen rules pattern-match, and marshals the `[Status BodyVal]` result back to +JSON. Storage is reached the other way: the Shen router calls +`(lua.call "host.store_add" ...)` against plain Lua functions backed by a +`lua_shared_dict`. See [`lua_interop.lua`](../../lua_interop.lua) for the +marshaling rules and the typed `(lua.function ...)` bridge. + +## Things to know before building on this + +- **Never use Shen's native file I/O for DB/network calls** — it's blocking and + would stall nginx's event loop. Reach the outside world through OpenResty's + non-blocking cosocket libraries (`lua-resty-mysql`, `lua-resty-http`, …) + called from Shen via `lua.call`, exactly as storage is here. +- **CPU-bound Shen blocks the worker.** A heavy Prolog query or typecheck holds + the worker until it returns. Fine for routing/validation; mind anything long. +- **Kernel state is per worker.** Globals don't cross workers — use + `lua_shared_dict`, Redis, or a DB for shared state (storage here is a shared + dict, so it's visible to every worker). +- **The bytecode cache** (`.shen-kernel-cache.bin`) is written in the worker's + cwd (the nginx prefix) on first boot; it's gitignored. + +## The front end and ShenScript + +`public/index.html` is plain HTML + `fetch()` so the example runs with nothing +but OpenResty. That hand-written JS is precisely the layer +[ShenScript](https://github.com/pyrex41/ShenScript) (Shen → JavaScript) would +replace. Because shen-lua (server) and ShenScript (browser) are two ports of +the **same language**, you can lift the field rules out of `validate.shen` into +a shared `.shen` module, compile it to JS for instant client-side validation, +and run the identical file on the server as the authoritative check — one type +system, no client/server drift. diff --git a/examples/openresty/app.lua b/examples/openresty/app.lua new file mode 100644 index 0000000..a7b3813 --- /dev/null +++ b/examples/openresty/app.lua @@ -0,0 +1,145 @@ +-- examples/openresty/app.lua — the glue between OpenResty and shen-lua. +-- +-- Loaded once per nginx worker (from init_worker_by_lua in nginx.conf): it +-- boots the Shen kernel, registers the typed Lua bridges, loads the typed +-- core (validate.shen) and the router (app.shen), and exposes M.handle() as +-- the content handler. Per request it marshals nginx data into Shen, calls +-- (route Method Path Body), and turns the [Status BodyVal] result into JSON. +-- +-- The expensive work (kernel boot ~ tens of ms warm, ~1 s cold) happens at +-- module-load time, i.e. ONCE per worker — never per request. This is the +-- single most important rule for running shen-lua under nginx. + +-- Resolve this file's own directory so the .shen loads are absolute and don't +-- depend on the nginx worker's cwd. +local APP_DIR = debug.getinfo(1, "S").source:match("^@(.*)[/\\][^/\\]+$") or "." + +local shen = require("shen") +local IO = require("lua_interop") -- the marshaling API (Lua array <-> Shen list) +local P = shen.prims -- F-table, load, ... + +-- cjson ships with OpenResty. Off-nginx (e.g. selftest.lua under plain +-- luajit) it may be absent, so fall back to a tiny self-contained JSON shim +-- with the same surface we use: decode, encode, and the empty_array sentinel. +local cjson +do + local ok, m = pcall(require, "cjson.safe") + if not ok then ok, m = pcall(require, "cjson") end + cjson = ok and m or assert(loadfile(APP_DIR .. "/json_shim.lua"))() +end + +-- A pluggable store so this module is testable off-nginx (selftest.lua injects +-- an in-memory table; under nginx M.use_store wires it to a lua_shared_dict). +local store = { + add = function(_, _) return 0 end, + list = function() return {} end, +} +local function set_store(s) store = s end + +-- ---- host services the Shen code calls (Shen -> Lua) ------------------------ +-- `host` is a global so the dotted paths below ("host.store_add", ...) resolve +-- through lua.call / lua.function, the same convention as examples/config_check. +host = { + -- string.match as a boolean predicate (Shen's stdlib has no Lua patterns) + matches = function(s, pat) return string.match(s, pat) ~= nil end, + -- storage, delegated to whatever store is installed + store_add = function(name, message) return store.add(name, message) end, + store_list = function() return store.list() end, +} + +shen.boot{quiet = true} + +-- Register the typed bridges, then load the typed core under (tc +) and the +-- router under (tc -). Mirrors examples/config_check.lua exactly. +shen.eval([[ + (lua.function strlen "string.len" [string --> number]) + (lua.function fmt "string.format" [string --> string --> string]) +]]) +shen.eval("(tc +)") +P.F["load"](APP_DIR .. "/validate.shen") +shen.eval("(tc -)") +P.F["load"](APP_DIR .. "/app.shen") + +local route = IO.fn("route") -- marshals args in and the result back out + +-- ---- marshaling: cjson value <-> Shen `val` --------------------------------- +local sym = IO.sym + +-- Lua (cjson-decoded) -> Shen `val`. Tags are interned SYMBOLS so the Shen +-- patterns [s X]/[n X]/... match; strings stay strings (never auto-interned). +local function to_val(v) + local t = type(v) + if t == "string" then return { sym("s"), v } end + if t == "number" then return { sym("n"), v } end + if t == "boolean" then return { sym("b"), v } end + if t == "table" then + if v[1] ~= nil or next(v) == nil then -- array (or empty) -> [arr ...] + local a = {} + for i, e in ipairs(v) do a[i] = to_val(e) end + return { sym("arr"), a } + end + local es, i = {}, 0 -- object -> [obj [[k v] ...]] + for k, val in pairs(v) do + if type(k) == "string" then i = i + 1; es[i] = { k, to_val(val) } end + end + return { sym("obj"), es } + end + return { sym("s"), tostring(v) } -- null / unsupported -> string +end + +-- Shen `val` (already marshaled by IO.fn to nested Lua arrays, tags as +-- strings) -> a plain Lua value ready for cjson.encode. +local function from_val(v) + local tag, payload = v[1], v[2] + if tag == "s" or tag == "n" or tag == "b" then return payload end + if tag == "arr" then + if #payload == 0 then return cjson.empty_array end + local out = {} + for i, e in ipairs(payload) do out[i] = from_val(e) end + return out + end + if tag == "obj" then + local o = {} + for _, pair in ipairs(payload) do o[pair[1]] = from_val(pair[2]) end + return o + end + return nil +end + +-- ---- the request handler ---------------------------------------------------- +-- Pure given (method, path, decoded-body); shared with selftest.lua. +local function dispatch(method, path, decoded_body) + local body = decoded_body and to_val(decoded_body) or nil + local resp = route(method, path, body) -- { Status, BodyVal } + return resp[1], from_val(resp[2]) +end + +local M = { dispatch = dispatch, to_val = to_val, from_val = from_val, + use_store = set_store, json = cjson } + +-- content_by_lua entry point. +function M.handle() + local method = ngx.req.get_method() + local path = ngx.var.uri + local decoded + if method == "POST" or method == "PUT" then + ngx.req.read_body() + local raw = ngx.req.get_body_data() + if raw and raw ~= "" then + local d, err = cjson.decode(raw) + if d == nil then + ngx.status = 400 + ngx.header.content_type = "application/json" + ngx.say(cjson.encode({ errors = { "invalid JSON: " .. tostring(err) } })) + return + end + decoded = d + end + end + local status, body = dispatch(method, path, decoded) + ngx.status = status + ngx.header.content_type = "application/json" + ngx.say(cjson.encode(body)) +end + +return M diff --git a/examples/openresty/app.shen b/examples/openresty/app.shen new file mode 100644 index 0000000..d8e8bba --- /dev/null +++ b/examples/openresty/app.shen @@ -0,0 +1,55 @@ +\\ app.shen — routing + storage orchestration (loaded with the typechecker OFF). +\\ +\\ This is the effectful "shell" around the typed core in validate.shen. It +\\ dispatches on HTTP method + path, calls the typed validators, and talks to +\\ storage through the Lua interop bridge — `host.store_add` / `host.store_list` +\\ are plain Lua functions backed by an nginx lua_shared_dict (see app.lua). +\\ I/O is effectful, so this half is untyped on purpose; it still calls the +\\ typed functions (validate-message, find-val) from validate.shen directly. +\\ +\\ Entry point: (route Method Path Body) -> [Status BodyVal], where BodyVal is +\\ a `val` (the same tagged shape as the input) that app.lua turns into JSON. + +\\ -- val constructors (build the JSON response) ------------------------------- +(define vstr X -> [s X]) +(define vbool X -> [b X]) +(define vnum X -> [n X]) +(define vobj Pairs -> [obj Pairs]) +(define varr Vs -> [arr Vs]) + +\\ -- GET /api/messages : list the guestbook ----------------------------------- +\\ host.store_list returns a Lua array of [name message] pairs, which the +\\ interop marshals to a Shen list of two-element lists. +(define row->val + [Name Message] -> (vobj [["name" (vstr Name)] ["message" (vstr Message)]]) + _ -> (vobj [])) + +(define list-messages + -> (let Rows (lua.call "host.store_list" []) + [200 (vobj [["messages" (varr (map (function row->val) Rows))]])])) + +\\ -- POST /api/messages : validate, then store -------------------------------- +(define field-string + K Es -> (first-string (find-val K Es))) + +(define first-string + [[s S]] -> S + _ -> "") + +(define store-message + [obj Es] -> (lua.call "host.store_add" + [(field-string "name" Es) (field-string "message" Es)]) + _ -> 0) + +(define create-message + Body -> (let Errs (validate-message Body) + (if (empty? Errs) + (do (store-message Body) + [201 (vobj [["ok" (vbool true)]])]) + [400 (vobj [["errors" (varr (map (function vstr) Errs))]])]))) + +\\ -- the router --------------------------------------------------------------- +(define route + "GET" "/api/messages" _ -> (list-messages) + "POST" "/api/messages" Body -> (create-message Body) + _ _ _ -> [404 (vobj [["error" (vstr "not found")]])]) diff --git a/examples/openresty/json_shim.lua b/examples/openresty/json_shim.lua new file mode 100644 index 0000000..0cd6bc6 --- /dev/null +++ b/examples/openresty/json_shim.lua @@ -0,0 +1,148 @@ +-- examples/openresty/json_shim.lua — a minimal JSON codec. +-- +-- Used ONLY when the real lua-cjson is unavailable (i.e. running the example +-- off-nginx under plain luajit, e.g. selftest.lua). OpenResty bundles cjson, +-- so under the actual server this file is never loaded. It implements just +-- the surface app.lua uses: decode, encode, and an empty_array sentinel. + +local json = {} + +-- A unique sentinel so an empty Lua table can be encoded as [] not {}. +json.empty_array = setmetatable({}, { __tostring = function() return "[]" end }) + +-- ---- encode ---------------------------------------------------------------- +local escapes = { ['"'] = '\\"', ['\\'] = '\\\\', ['\n'] = '\\n', + ['\r'] = '\\r', ['\t'] = '\\t' } +local function enc_str(s) + return '"' .. s:gsub('[%z\1-\31"\\]', function(c) + return escapes[c] or ('\\u%04x'):format(c:byte()) + end) .. '"' +end + +local encode +local function enc_table(v, out) + if v == json.empty_array then out[#out + 1] = "[]"; return end + local n = #v + local is_array = n > 0 + if is_array then + out[#out + 1] = "[" + for i = 1, n do + if i > 1 then out[#out + 1] = "," end + encode(v[i], out) + end + out[#out + 1] = "]" + else + -- object (or empty table -> {}) + out[#out + 1] = "{" + local first = true + for k, val in pairs(v) do + if not first then out[#out + 1] = "," end + first = false + out[#out + 1] = enc_str(tostring(k)); out[#out + 1] = ":" + encode(val, out) + end + out[#out + 1] = "}" + end +end + +encode = function(v, out) + local t = type(v) + if t == "string" then out[#out + 1] = enc_str(v) + elseif t == "number" then out[#out + 1] = tostring(v) + elseif t == "boolean" then out[#out + 1] = v and "true" or "false" + elseif t == "nil" then out[#out + 1] = "null" + elseif t == "table" then enc_table(v, out) + else error("json: cannot encode " .. t) end +end + +function json.encode(v) + local out = {} + encode(v, out) + return table.concat(out) +end + +-- ---- decode (small recursive-descent parser) ------------------------------- +local function decode_error(s, i, msg) + error(("json: %s at byte %d"):format(msg, i), 0) +end + +local parse_value +local function skip_ws(s, i) + local _, j = s:find("^[ \t\r\n]*", i) + return (j or i - 1) + 1 +end + +local function parse_string(s, i) + i = i + 1 -- skip opening quote + local buf = {} + while i <= #s do + local c = s:sub(i, i) + if c == '"' then return table.concat(buf), i + 1 + elseif c == '\\' then + local e = s:sub(i + 1, i + 1) + local map = { ['"'] = '"', ['\\'] = '\\', ['/'] = '/', n = '\n', + t = '\t', r = '\r', b = '\b', f = '\f' } + if map[e] then buf[#buf + 1] = map[e]; i = i + 2 + elseif e == 'u' then + local hex = s:sub(i + 2, i + 5) + buf[#buf + 1] = string.char(tonumber(hex, 16) % 256); i = i + 6 + else decode_error(s, i, "bad escape") end + else buf[#buf + 1] = c; i = i + 1 end + end + decode_error(s, i, "unterminated string") +end + +local function parse_number(s, i) + local j = s:find("[^%d%+%-eE%.]", i) or (#s + 1) + local num = tonumber(s:sub(i, j - 1)) + if not num then decode_error(s, i, "bad number") end + return num, j +end + +parse_value = function(s, i) + i = skip_ws(s, i) + local c = s:sub(i, i) + if c == '"' then return parse_string(s, i) + elseif c == '{' then + local obj = {} + i = skip_ws(s, i + 1) + if s:sub(i, i) == '}' then return obj, i + 1 end + while true do + local key; key, i = parse_string(s, skip_ws(s, i)) + i = skip_ws(s, i) + if s:sub(i, i) ~= ':' then decode_error(s, i, "expected ':'") end + local val; val, i = parse_value(s, i + 1) + obj[key] = val + i = skip_ws(s, i) + local d = s:sub(i, i) + if d == '}' then return obj, i + 1 + elseif d == ',' then i = skip_ws(s, i + 1) + else decode_error(s, i, "expected ',' or '}'") end + end + elseif c == '[' then + local arr = {} + i = skip_ws(s, i + 1) + if s:sub(i, i) == ']' then return arr, i + 1 end + while true do + local val; val, i = parse_value(s, i) + arr[#arr + 1] = val + i = skip_ws(s, i) + local d = s:sub(i, i) + if d == ']' then return arr, i + 1 + elseif d == ',' then i = i + 1 + else decode_error(s, i, "expected ',' or ']'") end + end + elseif s:sub(i, i + 3) == "true" then return true, i + 4 + elseif s:sub(i, i + 4) == "false" then return false, i + 5 + elseif s:sub(i, i + 3) == "null" then return nil, i + 4 + else return parse_number(s, i) end +end + +-- cjson.safe-style: returns nil, errmsg on failure rather than throwing. +function json.decode(s) + local ok, v = pcall(parse_value, s, 1) + if not ok then return nil, v end + return v +end + +return json diff --git a/examples/openresty/nginx.conf b/examples/openresty/nginx.conf new file mode 100644 index 0000000..eb37ad3 --- /dev/null +++ b/examples/openresty/nginx.conf @@ -0,0 +1,73 @@ +# examples/openresty/nginx.conf — run the Shen guestbook app under OpenResty. +# +# Run it from the repo root with the example dir as the nginx prefix: +# +# mkdir -p examples/openresty/logs +# openresty -p "$PWD/examples/openresty" -c nginx.conf +# +# then open http://127.0.0.1:8080/ . The prefix is this directory, so the +# relative paths below (logs/, public/) resolve here, and init_by_lua derives +# the repo root from the prefix to put shen-lua on package.path. + +daemon off; +worker_processes 1; # one worker = one kernel boot, simplest demo +pid logs/nginx.pid; +error_log logs/error.log info; + +events { worker_connections 256; } + +http { + access_log logs/access.log; + + # The guestbook lives in a shared dict so it survives across requests + # (and would be shared across workers if you raised worker_processes). + lua_shared_dict guestbook 1m; + + # Put shen-lua (repo root) and this example dir on package.path. The prefix + # is .../examples/openresty/, so ../../ is the repo root. + init_by_lua_block { + local prefix = ngx.config.prefix() + package.path = prefix .. "../../?.lua;" .. prefix .. "?.lua;" .. package.path + } + + # Boot Shen ONCE per worker (kernel load + typed load of validate.shen), + # then wire storage to the shared dict. Never do this per request. + init_worker_by_lua_block { + local app = require("app") + local dict = ngx.shared.guestbook + app.use_store({ + add = function(name, message) + local n = dict:incr("count", 1, 0) + dict:set("row:" .. n, name .. "\t" .. message) + return n + end, + list = function() + local n, rows = dict:get("count") or 0, {} + for i = 1, n do + local v = dict:get("row:" .. i) + if v then + local name, msg = v:match("^(.-)\t(.*)$") + rows[#rows + 1] = { name, msg } + end + end + return rows + end, + }) + } + + server { + listen 8080; + + # The JSON API — every request goes through (route ...) in Shen. + location /api/ { + content_by_lua_block { require("app").handle() } + } + + # The front end. Today a plain HTML+fetch page; the natural next step + # is to compile a Shen UI to JS with ShenScript and serve it here. + location / { + root public; # relative to the nginx prefix + index index.html; + } + } +} diff --git a/examples/openresty/public/index.html b/examples/openresty/public/index.html new file mode 100644 index 0000000..dc0e11a --- /dev/null +++ b/examples/openresty/public/index.html @@ -0,0 +1,88 @@ + + + + + + + shen-lua · OpenResty guestbook + + + +

Guestbook — validated by Shen

+

Every entry is checked by typed rules in + validate.shen and routed by app.shen, + running on shen-lua inside OpenResty.

+ +
+ + +
+ +
+ +
    + + + + diff --git a/examples/openresty/selftest.lua b/examples/openresty/selftest.lua new file mode 100644 index 0000000..e586300 --- /dev/null +++ b/examples/openresty/selftest.lua @@ -0,0 +1,51 @@ +-- examples/openresty/selftest.lua — verify the Shen app off-nginx. +-- +-- luajit examples/openresty/selftest.lua (from the repo root) +-- +-- Boots the same app.lua the server uses, swaps in an in-memory store, and +-- drives (route ...) through app.lua's dispatch() with sample requests. This +-- exercises the typed load of validate.shen (a type error there aborts here, +-- before nginx is ever involved) and the routing/validation/storage paths. + +local root = arg[0]:match("^(.*)/examples/openresty/[^/]+$") or "." +package.path = root .. "/?.lua;" .. root .. "/examples/openresty/?.lua;" .. package.path + +local app = require("app") + +-- in-memory stand-in for the nginx lua_shared_dict +local rows = {} +app.use_store({ + add = function(name, message) rows[#rows + 1] = { name, message }; return #rows end, + list = function() return rows end, +}) + +local cjson = app.json -- the codec app.lua resolved (real cjson or the shim) +local function show(label, method, path, body) + local status, resp = app.dispatch(method, path, body) + print(("%-28s -> %d %s"):format(label, status, cjson.encode(resp))) + return status +end + +local fail = 0 +local function expect(label, want, method, path, body) + local got = show(label, method, path, body) + if got ~= want then fail = fail + 1; print((" FAIL: expected %d"):format(want)) end +end + +print("== guestbook API (in-memory store) ==") +expect("GET empty list", 200, "GET", "/api/messages") +expect("POST valid", 201, "POST", "/api/messages", + { name = "ada", message = "first post" }) +expect("POST another valid", 201, "POST", "/api/messages", + { name = "grace", message = "hello from shen" }) +expect("GET list (2 rows)", 200, "GET", "/api/messages") +expect("POST missing name", 400, "POST", "/api/messages", + { message = "anon" }) +expect("POST blank message", 400, "POST", "/api/messages", + { name = "bob", message = "" }) +expect("POST not an object", 400, "POST", "/api/messages", + { "not", "an", "object" }) +expect("unknown route -> 404", 404, "GET", "/api/nope") + +if fail == 0 then print("\nOK — all cases passed") +else print(("\n%d case(s) FAILED"):format(fail)); os.exit(1) end diff --git a/examples/openresty/validate.shen b/examples/openresty/validate.shen new file mode 100644 index 0000000..af57cd3 --- /dev/null +++ b/examples/openresty/validate.shen @@ -0,0 +1,84 @@ +\\ validate.shen — the TYPED core of the guestbook app. +\\ +\\ Loaded by app.lua with the typechecker ON ((tc +) is done first, and Shen's +\\ `load` snapshots the tc mode once at load start). Every rule below is +\\ proved sound at load time: a type error in a validator is rejected before +\\ the server ever handles a request, exactly like examples/config_rules.shen. +\\ +\\ This is the "pure typed core" half of the layered design. The effectful +\\ half — routing and storage, which touch nginx and a shared dict — lives in +\\ app.shen and runs untyped (I/O is inherently effectful). The two files load +\\ into the same environment, so app.shen calls these typed functions freely. + +\\ -- the value space of a decoded JSON request body --------------------------- +\\ app.lua marshals a cjson-decoded body into the same tagged `val` shape used +\\ by examples/config_rules.shen: +\\ "x" -> [s "x"] true -> [b true] 8080 -> [n 8080] +\\ [...] -> [arr [...]] {k:v,...} -> [obj [[k v] ...]] +(datatype val + X : string; + ============ + [s X] : val; + + X : number; + ============ + [n X] : val; + + X : boolean; + ============ + [b X] : val; + + Es : (list entry); + ================== + [obj Es] : val; + + Vs : (list val); + ================ + [arr Vs] : val;) + +(datatype entry + K : string; V : val; + ==================== + [K V] : entry;) + +\\ -- typed helpers ------------------------------------------------------------ + +\\ key lookup in an object: [] = absent, [V] = present (a poor man's maybe). +\\ Also used (untyped) from app.shen to pull fields out of a request body. +(define find-val + {string --> (list entry) --> (list val)} + _ [] -> [] + K [[K V] | _] -> [V] + K [_ | Es] -> (find-val K Es)) + +\\ build one "field: message" string. `fmt` is the typed bridge to +\\ string.format, declared [string --> string --> string], so it takes exactly +\\ one %s arg — we assemble the rest with the kernel's own typed string ops +\\ (`cn` concatenates, `str` renders a number). Max = 0 means "no bound to show". +(define field-error + {string --> string --> number --> string --> string} + Field Msg 0 _ -> (fmt (cn Field ": %s") Msg) + Field Msg Max Tl -> (fmt (cn Field ": %s") (cn Msg (cn (str Max) Tl)))) + +\\ a required string field, present and within a length bound. `strlen` is the +\\ typed bridge to Lua's string.len (declared from app.lua before this load). +(define check-string + {string --> number --> (list val) --> (list string)} + Field Max [[s S]] -> (if (and (> (strlen S) 0) (<= (strlen S) Max)) + [] + [(field-error Field "must be 1.." Max " characters")]) + Field _ [_] -> [(field-error Field "must be a string" 0 "")] + Field _ [] -> [(field-error Field "is required" 0 "")]) + +\\ -- the rules ---------------------------------------------------------------- +\\ A guestbook entry is { "name": string(1..40), "message": string(1..280) }. + +(define validate-message + {val --> (list string)} + [obj Es] -> (append (check-string "name" 40 (find-val "name" Es)) + (check-string "message" 280 (find-val "message" Es))) + _ -> ["body: must be a JSON object"]) + +(define valid-message? + {val --> boolean} + B -> (empty? (validate-message B))) From 59b76b6251dd0d0b63eb1df7bfa9bdd8c21f494d Mon Sep 17 00:00:00 2001 From: Reuben Brooks Date: Mon, 29 Jun 2026 10:38:23 -0500 Subject: [PATCH 2/2] openresty example: share one rules.shen across server and browser, shaken for fast client load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the OpenResty guestbook's validation rules a single source of truth that runs on both ends, and wire up the ShenScript client path the example only gestured at before. - rules.shen: the field rules as one pure, typed, portable Shen file. The server loads it under (tc +) (replacing validate.shen — same guarantees, now built with cn/str so it needs no Lua bridges and stays portable). - Browser validation now runs the SAME rules: scripts/build-client.sh shakes rules.shen (+ a 4-line marshaling glue) with Ratatoskr down to ~100 reachable kernel defuns (eval/reader/typechecker stripped, needs-eval=false), and ShenScript compiles that slice to a self-contained ES module (public/vendor/shen-rules.client.js, ~140 KB, ~15 ms init vs ~660 KB / ~2.3 s for the full kernel bundle). Committed so the demo runs with no extra checkout. - index.html: imports the shaken validator, checks the form in-browser before POSTing (invalid never hits the network), and gained a self-documenting "what this demonstrates" panel, a flow diagram, and an inline rules.shen view. - nginx.conf: serve /rules.shen, and add a minimal MIME map so the ES module is served as text/javascript (browsers enforce JS MIME for module scripts). - Verified end-to-end under real OpenResty + a real browser; selftest still green. Co-Authored-By: Claude Opus 4.8 --- README.md | 10 +- examples/README.md | 2 +- examples/openresty/README.md | 122 +- examples/openresty/app.lua | 15 +- examples/openresty/nginx.conf | 24 +- examples/openresty/public/index.html | 192 ++- .../public/vendor/shen-rules.client.js | 1383 +++++++++++++++++ examples/openresty/rules.shen | 80 + examples/openresty/scripts/build-client.mjs | 102 ++ examples/openresty/scripts/build-client.sh | 45 + examples/openresty/scripts/client.glue.shen | 10 + examples/openresty/validate.shen | 84 - 12 files changed, 1907 insertions(+), 162 deletions(-) create mode 100644 examples/openresty/public/vendor/shen-rules.client.js create mode 100644 examples/openresty/rules.shen create mode 100644 examples/openresty/scripts/build-client.mjs create mode 100755 examples/openresty/scripts/build-client.sh create mode 100644 examples/openresty/scripts/client.glue.shen delete mode 100644 examples/openresty/validate.shen diff --git a/README.md b/README.md index f03d65e..e08d986 100644 --- a/README.md +++ b/README.md @@ -257,12 +257,20 @@ exhaustively at the top of [`lua_interop.lua`](lua_interop.lua). ## Examples +The flagship is **[`examples/openresty/`](examples/openresty/)** — a complete +guestbook web app whose validation rules are written once in Shen and run on +*both* ends: as a typechecked core on the server (shen-lua inside OpenResty) and +as a [Ratatoskr](https://github.com/pyrex41/ratatoskr)-shaken, +[ShenScript](https://github.com/pyrex41/ShenScript)-compiled module in the +browser. One `rules.shen`, two runtimes, no client/server drift. See its +[README](examples/openresty/README.md) for the walkthrough. + | | | |---|---| | [`examples/hello_embed.lua`](examples/hello_embed.lua) | the smallest useful embedding: boot, define a typed function, call it both ways (~25 lines) | | [`examples/family.shen`](examples/family.shen) | Shen Prolog in twenty lines: facts, rules, queries via `bin/shen` | | [`examples/config_check.lua`](examples/config_check.lua) | the showcase: Shen datatypes + rules as a **typed validation layer** for nested Lua config tables — the typechecker rejects buggy rules at load time ([walkthrough](examples/README.md)) | -| [`examples/openresty/`](examples/openresty/) | a **complete web app in Shen on OpenResty** (nginx + LuaJIT): typed request validators + a Shen router behind a JSON API, with a plain-HTML front end. Runs standalone (`luajit examples/openresty/selftest.lua`) or under `openresty` ([README](examples/openresty/README.md)) | +| [`examples/openresty/`](examples/openresty/) | a **complete web app in Shen on OpenResty** (nginx + LuaJIT): typed request validators + a Shen router behind a JSON API, with a front end that runs the **same** typed rules in the browser — Ratatoskr-shaken and ShenScript-compiled to a ~140 KB module. One `rules.shen`, validated client- and server-side. Runs standalone (`luajit examples/openresty/selftest.lua`) or under `openresty` ([README](examples/openresty/README.md)) | ## Certification / Testing diff --git a/examples/README.md b/examples/README.md index 17dd171..4631b5e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,7 +8,7 @@ Four examples, smallest first (the first three run with plain | [`hello_embed.lua`](hello_embed.lua) | the smallest useful embedding: boot, define a typed Shen function, call it from Lua, pass lists both ways. `luajit examples/hello_embed.lua` | | [`family.shen`](family.shen) | Shen Prolog in twenty lines — facts, rules, yes/no and binding queries. `bin/shen examples/family.shen` | | [`config_check.lua`](config_check.lua) | the showcase, walked through below. `luajit examples/config_check.lua` | -| [`openresty/`](openresty/) | a complete web app — typed Shen validators + a Shen router on OpenResty (nginx + LuaJIT), with a plain-HTML front end. Runs standalone via `luajit examples/openresty/selftest.lua`; see [its README](openresty/README.md) to serve it. | +| [`openresty/`](openresty/) | a complete web app — typed Shen validators + a Shen router on OpenResty (nginx + LuaJIT), with a front end that runs the **same** rules in the browser (Ratatoskr-shaken, ShenScript-compiled). Runs standalone via `luajit examples/openresty/selftest.lua`; see [its README](openresty/README.md) to serve it. | --- diff --git a/examples/openresty/README.md b/examples/openresty/README.md index 324e27a..5cc0a73 100644 --- a/examples/openresty/README.md +++ b/examples/openresty/README.md @@ -6,15 +6,24 @@ a JSON API plus a one-file front end. The point isn't the guestbook — it's the shape of a real Shen web app and the handful of rules that make it work. OpenResty is nginx embedding **LuaJIT 2.1**, which is exactly shen-lua's primary -host, so the backend can literally be Shen with a thin Lua glue layer. +host, so the backend can literally be Shen with a thin Lua glue layer. The front +end runs Shen too: the browser validates with a build of the **same** +`rules.shen`, compiled to JavaScript by +[ShenScript](https://github.com/pyrex41/ShenScript) and tree-shaken by +[Ratatoskr](https://github.com/pyrex41/ratatoskr), so the field rules are +checked client-side AND server-side from one source of truth. ``` examples/openresty/ - validate.shen the TYPED core — field rules, proved sound at load time + rules.shen the TYPED core — field rules, proved sound at load time; + loaded by the server AND shaken into the browser build app.shen the router — dispatch + storage orchestration (untyped shell) app.lua the glue — boots Shen, marshals JSON <-> Shen, the handler - nginx.conf OpenResty config: boot once per worker, two locations - public/ the front end (plain HTML+fetch; the ShenScript slot) + nginx.conf OpenResty config: boot once per worker, serve rules.shen + public/ the front end (imports the shaken validator module) + public/vendor/shen-rules.client.js the shaken+compiled client validator + (~140 KB, generated from rules.shen — see "Front end" below) + scripts/ build-client.sh + helpers that regenerate that module selftest.lua drives the whole app under plain luajit, no nginx needed json_shim.lua a tiny JSON codec used only off-nginx (OpenResty has cjson) ``` @@ -41,7 +50,9 @@ OK — all cases passed ## Run it under OpenResty -With `openresty` on your PATH, from the repo root: +The browser validator (`public/vendor/shen-rules.client.js`) is committed, so +there is nothing to vendor — just run it. With `openresty` on your PATH, from +the repo root: ```sh mkdir -p examples/openresty/logs @@ -64,32 +75,41 @@ here; `init_by_lua` derives the repo root from the prefix to put shen-lua on ## How it fits together ``` -browser ──HTTP──> nginx location /api/ ──> app.lua: handle() - │ JSON -> Shen `val` - ▼ - (route Method Path Body) app.shen [untyped shell] - │ └─ (validate-message Body) validate.shen [typed core] - │ └─ (lua.call "host.store_*") -> lua_shared_dict - ▼ - [Status BodyVal] - │ Shen `val` -> JSON - ▼ - HTTP response +browser ─ validate-message in a shaken build of rules.shen ─ instant feedback + │ HTTP (only client-valid requests) + ▼ +nginx location /api/ ──> app.lua: handle() + │ JSON -> Shen `val` + ▼ + (route Method Path Body) app.shen [untyped shell] + │ └─ (validate-message Body) rules.shen [typed core] + │ └─ (lua.call "host.store_*") -> lua_shared_dict + ▼ + [Status BodyVal] + │ Shen `val` -> JSON + ▼ + HTTP response + +The browser runs validate-message from a tree-shaken build of rules.shen before +posting; the server re-runs rules.shen as the authoritative check. One source, +both ends. ``` ### Boot once per worker — the one rule that matters -Booting the Shen kernel (and typechecking `validate.shen`) costs real time — +Booting the Shen kernel (and typechecking `rules.shen`) costs real time — tens of ms warm from the bytecode cache, ~1 s cold. `nginx.conf` does it in `init_worker_by_lua`, so it happens **once per worker, never per request**. A warm worker handles requests with no per-call boot cost. ### Typed core, untyped shell -- **`validate.shen` loads under `(tc +)`.** Its field rules are checked by +- **`rules.shen` loads under `(tc +)`.** Its field rules are checked by Shen's sequent-calculus typechecker *at load time* — a type error in a validator aborts startup, before the server ever takes a request (the same - guarantee as [`examples/config_rules.shen`](../config_rules.shen)). + guarantee as [`examples/config_rules.shen`](../config_rules.shen)). It is + pure, portable Shen (`cn`/`str`/`tlstr` only, no host bridges), which is + exactly why the browser can load the same file. - **`app.shen` loads under `(tc -)`.** Routing and storage are effectful (they touch nginx and a shared dict), so this half is untyped on purpose. It still calls the typed validators directly — both files load into one environment. @@ -102,7 +122,9 @@ Shen rules pattern-match, and marshals the `[Status BodyVal]` result back to JSON. Storage is reached the other way: the Shen router calls `(lua.call "host.store_add" ...)` against plain Lua functions backed by a `lua_shared_dict`. See [`lua_interop.lua`](../../lua_interop.lua) for the -marshaling rules and the typed `(lua.function ...)` bridge. +marshaling rules. (The browser does the same marshaling in JS — see +`public/index.html`, where two form strings become a `val` for the in-browser +`validate-message`.) ## Things to know before building on this @@ -118,13 +140,55 @@ marshaling rules and the typed `(lua.function ...)` bridge. - **The bytecode cache** (`.shen-kernel-cache.bin`) is written in the worker's cwd (the nginx prefix) on first boot; it's gitignored. -## The front end and ShenScript +## The front end: Shen in the browser, tree-shaken -`public/index.html` is plain HTML + `fetch()` so the example runs with nothing -but OpenResty. That hand-written JS is precisely the layer -[ShenScript](https://github.com/pyrex41/ShenScript) (Shen → JavaScript) would -replace. Because shen-lua (server) and ShenScript (browser) are two ports of -the **same language**, you can lift the field rules out of `validate.shen` into -a shared `.shen` module, compile it to JS for instant client-side validation, -and run the identical file on the server as the authoritative check — one type -system, no client/server drift. +`public/index.html` runs Shen **in the browser**. It imports +`public/vendor/shen-rules.client.js`, calls `createValidator()`, and uses the +result to check the form *in the browser* (an invalid entry never reaches the +network); only a client-valid entry is POSTed, where the server re-runs +`rules.shen` as the authoritative check. + +That client module is not the whole ShenScript kernel — it is a **tree-shaken +build of `rules.shen`**: + +1. [Ratatoskr](https://github.com/pyrex41/ratatoskr), a Shen tree-shaker, walks + the kernel call graph and emits only the ~100 kernel functions these rules + can reach. Because the rules never touch `eval`/`read`/`tc`, the reader, the + macro expander, the typechecker and `eval` itself all fall away + (`needs-eval=false` in the manifest). +2. [ShenScript](https://github.com/pyrex41/ShenScript) compiles that slice to + JavaScript ahead of time, and `scripts/build-client.mjs` wraps it as a + self-contained ES module that exports `createValidator()`. + +The result is ~140 KB and inits in tens of milliseconds, versus ~660 KB and +~2.3 s for the full ShenScript kernel bundle. It embeds ShenScript's pure +`runtime.js`/`overrides.js`, so it needs no ShenScript checkout or npm install +at runtime — just the committed file. + +This is the payoff of shen-lua (server) and ShenScript (browser) being two +ports of the **same language**: the field rules live in one typed `.shen` file, +proved sound at server startup and *generated into* the client build — one type +system, no client/server drift. The only browser-only code is the marshaling +glue ([`scripts/client.glue.shen`](scripts/client.glue.shen), four lines that +turn two form strings into the tagged `val` the rules match); everything about +*what counts as valid* lives in `rules.shen`. + +### Regenerating the client module + +The committed `shen-rules.client.js` is generated; rerun the build whenever +`rules.shen` changes so client and server stay in lockstep: + +```sh +examples/openresty/scripts/build-client.sh +``` + +It needs sibling checkouts of [Ratatoskr](https://github.com/pyrex41/ratatoskr) +(the `ratatoskr` binary) and [ShenScript](https://github.com/pyrex41/ShenScript), +plus `luajit` (the shake host) and Node 20+. Override locations with +`$RATATOSKR` and `$SHENSCRIPT_DIR`. The script concatenates `rules.shen` + +`scripts/client.glue.shen`, shakes the slice, and compiles it to the module. + +The running page is self-documenting too: a "What this demonstrates" panel with +a browser→server flow diagram, links to the live `/rules.shen`, and an +expandable view of the rule source — so anyone opening the example sees the +rules and the architecture without reading the code. diff --git a/examples/openresty/app.lua b/examples/openresty/app.lua index a7b3813..e822f8f 100644 --- a/examples/openresty/app.lua +++ b/examples/openresty/app.lua @@ -40,8 +40,6 @@ local function set_store(s) store = s end -- `host` is a global so the dotted paths below ("host.store_add", ...) resolve -- through lua.call / lua.function, the same convention as examples/config_check. host = { - -- string.match as a boolean predicate (Shen's stdlib has no Lua patterns) - matches = function(s, pat) return string.match(s, pat) ~= nil end, -- storage, delegated to whatever store is installed store_add = function(name, message) return store.add(name, message) end, store_list = function() return store.list() end, @@ -49,14 +47,13 @@ host = { shen.boot{quiet = true} --- Register the typed bridges, then load the typed core under (tc +) and the --- router under (tc -). Mirrors examples/config_check.lua exactly. -shen.eval([[ - (lua.function strlen "string.len" [string --> number]) - (lua.function fmt "string.format" [string --> string --> string]) -]]) +-- Load the typed core under (tc +) and the router under (tc -). rules.shen is +-- the SAME file the browser loads via ShenScript (see public/index.html) — one +-- source of truth for the field rules. It needs no Lua bridges: it is pure, +-- portable Shen (cn/str/tlstr only), which is exactly why it runs unchanged on +-- both ports. A type error in it aborts startup, before the first request. shen.eval("(tc +)") -P.F["load"](APP_DIR .. "/validate.shen") +P.F["load"](APP_DIR .. "/rules.shen") shen.eval("(tc -)") P.F["load"](APP_DIR .. "/app.shen") diff --git a/examples/openresty/nginx.conf b/examples/openresty/nginx.conf index eb37ad3..e748bdb 100644 --- a/examples/openresty/nginx.conf +++ b/examples/openresty/nginx.conf @@ -19,6 +19,18 @@ events { worker_connections 256; } http { access_log logs/access.log; + # Minimal MIME map (self-contained, so this config needs no mime.types + # include). The .js type matters: the front end loads the shaken validator + # as an ES module, and browsers enforce a JS MIME type for module scripts. + types { + text/html html htm; + text/css css; + text/javascript js mjs; + application/json json; + image/svg+xml svg; + } + default_type application/octet-stream; + # The guestbook lives in a shared dict so it survives across requests # (and would be shared across workers if you raised worker_processes). lua_shared_dict guestbook 1m; @@ -63,8 +75,16 @@ http { content_by_lua_block { require("app").handle() } } - # The front end. Today a plain HTML+fetch page; the natural next step - # is to compile a Shen UI to JS with ShenScript and serve it here. + # Serve the shared rule file itself, so the browser fetches the SAME + # rules.shen the server loaded and runs it client-side via ShenScript. + # alias points at the file next to app.shen (one source of truth). + location = /rules.shen { + default_type text/plain; + alias rules.shen; # relative to the nginx prefix + } + + # The front end: ShenScript (Shen -> JS) loads /rules.shen in the + # browser for instant client-side validation with the same rules. location / { root public; # relative to the nginx prefix index index.html; diff --git a/examples/openresty/public/index.html b/examples/openresty/public/index.html index dc0e11a..c5d8a22 100644 --- a/examples/openresty/public/index.html +++ b/examples/openresty/public/index.html @@ -2,18 +2,17 @@ @@ -21,64 +20,185 @@ shen-lua · OpenResty guestbook -

    Guestbook — validated by Shen

    -

    Every entry is checked by typed rules in - validate.shen and routed by app.shen, - running on shen-lua inside OpenResty.

    +

    Guestbook — validated by Shen, on both ends

    +

    The field rules live in one file, + rules.shen, and run + twice: in your browser (a Ratatoskr-shaken, + ShenScript-compiled build of those rules) for + instant feedback, and on the server (shen-lua under OpenResty) as the + authoritative check. Sign the guestbook below — then see + how it works ↓.

    + +

    loading the Shen validator…

    - +
      -