├── .dtp ├── soakbean.gif └── soakbean.jpg ├── Dockerfile ├── README.md ├── make ├── middleware ├── blacklisturl.lua └── json.lua ├── soakbean.com └── src ├── .init.lua ├── .lua ├── json.lua └── soakbean.lua ├── data.lua ├── index.html └── tests.html /.dtp/soakbean.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coderofsalvation/soakbean/d669e21e1afcc3cf69cb93661ad74515e24d8601/.dtp/soakbean.gif -------------------------------------------------------------------------------- /.dtp/soakbean.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coderofsalvation/soakbean/d669e21e1afcc3cf69cb93661ad74515e24d8601/.dtp/soakbean.jpg -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | FROM scratch 3 | ADD soakbean.com / 4 | 5 | CMD ["/soakbean.com"] 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Reactive plugnplay middleware for redbean 4 | 5 | Write beautiful ~2.3MB [redbean (docker) apps](https://redbean.dev) like this [.init.lua](src/.init.lua): 6 | 7 | ## Beautiful micro stack 8 | 9 | 10 | 11 | * re-use middleware functions across redbean projects 12 | * reactive programming (write less code) 13 | * easy express-style routing 14 | * easily adapt to redbean API changes 15 | 16 | ## Syntactic sugar 17 | 18 | ```lua 19 | app = require("soakbean") { 20 | bin = "./soakbean.com", 21 | opts = { my_cli_arg=0 }, 22 | cmd={ 23 | -- runtask = {file="sometask.lua", info="description of cli cmd"} 24 | }, 25 | title = 'SOAKBEAN - a buddy of redbean', 26 | } 27 | 28 | app.url['^/data'] = '/data.lua' -- setup custom file endpoint 29 | -- 30 | app.get('^/', app.template('index.html') ) -- alias for app.tpl( LoadAsset('index.html'), app ) 31 | -- also see app.post app.put and so on 32 | app -- 33 | .use( require("json").middleware() ) -- try plug'n'play json API middleware 34 | .use( app.router( app.url ) ) -- try url router 35 | .use( app.response() ) -- try serve app response (if any) 36 | .use( function(req,next) Route() end) -- fallback default redbean fileserver 37 | 38 | function OnHttpRequest() app.run() end 39 | ``` 40 | 41 | Just run it straight from the repository using [redbean.com](https://redbean.dev) itself:
42 | 43 | ``` 44 | $ git clone https://github.com/coderofsalvation/soakbean && cd src 45 | $ redbean.com -D . --my-cli-arg=abc 46 | ``` 47 | 48 | Then you can easily package the current directory into your own (renamed redbean) COM-file: 49 | 50 | ``` 51 | $ sed -i 's/NAME=soakbean/NAME=yourapp/g' 52 | $ ./make all 53 | $ ./yourapp.com --my-cli-arg=abc 54 | ``` 55 | 56 | > Profit! 57 | 58 | 59 | ## Getting started 60 | 61 | | Lazy | Recommended | Docker | 62 | |-|-|-| 63 | | download [soakbean.com](https://github.com/coderofsalvation/soakbean/raw/master/soakbean.com), add html (and/or lua) files using a zip-filemanager, and run `./soakbean.com` | download [redbean.com](https://redbean.dev) and run it in the `src` folder of this repo (see above cmdline) | clone this repo and run `./make docker` and surf to `http://localhost:8080` | 64 | 65 | > middleware: copy [middleware](middleware) functions to `src/.lua`-folder where needed 66 | 67 | ## Cute simple backend<->frontend traffic 68 | 69 | Just look at how cute this [index.html](src/index.html) combines serverside templating with RESTful & DOM-reactive templating: 70 | 71 | ``` 72 | ${title} <-- evaluated serverside --> 73 | <-- evaluated clientside --> 74 | <-- evaluated clientside using REST call to server --> 75 | 76 | ``` 77 | 78 | 79 | ## Middleware functions 80 | 81 | You can easily manipulate the http-request flow, using existing middleware functions: 82 | 83 | ```lua 84 | app.use( 85 | require("blacklisturl")({ 86 | "^/secret/", 87 | "^/me-fainting-next-to-justinbieber.mp4" 88 | }) 89 | ) 90 | ``` 91 | 92 | > make sure you copy [middleware/blacklisturl.lua](middleware/blacklisturl.lua) to [src/.lua](src/.lua) 93 | 94 | or just write ad-hoc middleware: 95 | 96 | ```lua 97 | app.use( function(req,res,next) 98 | res.status(200) 99 | res.header('content-type','text/html') 100 | res.body('hello world') 101 | next() -- comment this to prevent further middleware altering body, status headers e.g. 102 | end) 103 | ``` 104 | 105 | ```lua 106 | app.use( function(req,res,next) 107 | if !req.loggedin && req.url:match("^/mydata") then 108 | res.status(403) 109 | else next() 110 | end) 111 | ``` 112 | 113 | 114 | > WANTED: please contribute your [middleware](middleware) functions by pushing repositories with nameconvention `soakbean-middleware-`. Everybody loves (re)using battle-tested middleware. 115 | 116 | ## req & res object 117 | 118 | | key | type | alias for redbean | 119 | |-|-|-| 120 | | `req.method` | string | `GetMethod()` | 121 | | `req.url` | string | `GetPath()` | 122 | | `req.param` | table | `GetParams()` | 123 | | `req.host` | string | `GetHost()` | 124 | | `req.header` | table | `GetHeaders()` | 125 | | `req.protocol` | string | `GetScheme()` | 126 | | `req.body` | table or string | | 127 | | `res.body(value)` | string | `Write(value) including auto-encoding (json e.g.)` | 128 | | `res.status(code)` | int | `SetStatus(code)` | 129 | | `res.header(type,value)` | string,string | `SetHeader(type,value)` | 130 | 131 | ## Simple template evaluation 132 | 133 | index.html 134 | ``` 135 | ${title} 136 | ``` 137 | 138 | lua 139 | ``` 140 | app.title = "hello world" 141 | app.get('^/', app.template('index.html') ) 142 | ``` 143 | 144 | > NOTE: this is basically serving the output of `app.tpl( LoadAsset('index.html'), app )` 145 | 146 | ## Reactive programming 147 | 148 | #### react to variable changes: 149 | 150 | ```lua 151 | app.on("foo", function(k,v) 152 | print("appname changed: " .. v) 153 | end) 154 | 155 | app.foo = "flop" -- output: appname changed: flop 156 | app.foo = "bar" -- output: appname changed: bar 157 | ``` 158 | 159 | #### react to function calls 160 | 161 | ```lua 162 | app.on('foobar', function(a) 163 | print("!") 164 | end) 165 | 166 | app.foobar = function(a) 167 | print('foobar') 168 | end 169 | 170 | app.foobar() -- output: foobar! 171 | ``` 172 | 173 | #### react to router patterns 174 | 175 | ```lua 176 | app.url['^/foo'] = '/somefile.lua' 177 | 178 | app.on('^/foo', function(a,b) 179 | -- do something 180 | end) 181 | ``` 182 | 183 | #### react to luafile endpoint execution 184 | 185 | ```lua 186 | app.url['^/foo'] = '/somefile.lua' 187 | 188 | app.on('somefile.lua', function(a,b) 189 | -- do something 190 | end) 191 | ``` 192 | 193 | #### react to response code/header/body changes 194 | 195 | ```lua 196 | app.on('res.status', print ) 197 | app.on('res.body' , print ) 198 | app.on('res.header', function(k,v) 199 | print(k .. " => " .. v) 200 | end) 201 | ``` 202 | 203 | > NOTE: above is handy for debugging a flow. Use `app.use(..)` for more control. 204 | 205 | ## Roadmap / Scope 206 | 207 | * scope is backend, not frontend 208 | * http auth (*) 209 | * middleware: sqlite user sessions (*) 210 | * middleware: sqlite tiny job queue (*) 211 | * middleware: sqlite tiny rule engine (*) 212 | * middleware: sqlite CRUD middleware (endpoints + sqlite schema derived from jsonschema) (*) 213 | 214 | \* = please contribute! =] 215 | -------------------------------------------------------------------------------- /make: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | REDBEAN_STATIC=redbean.com 3 | VERSION=2.2 4 | NAME=soakbean 5 | 6 | to_elf(){ 7 | zip -d $NAME.com .ape 8 | zip -sf $NAME.com 9 | ./$NAME.com -h &>/dev/null 10 | } 11 | 12 | all(){ 13 | set -x 14 | [[ ! -f $REDBEAN_STATIC ]] && wget https://redbean.dev/redbean-${VERSION}.com -O $REDBEAN_STATIC 15 | [[ -f $REDBEAN_STATIC ]] && cp $REDBEAN_STATIC $NAME.com 16 | cp middleware/json.lua src/.lua/. 17 | cd src 18 | zip -q -r ../$NAME.com * .lua .init.lua 19 | chmod 755 ../$NAME.com 20 | set +x 21 | cd - 22 | ls -lah $NAME.com 23 | } 24 | 25 | docker(){ 26 | docker=$(which docker) 27 | to_elf 28 | set -x 29 | $docker build . -t $NAME && \ 30 | $docker images | grep $NAME && \ 31 | $docker run -p 8080:8080 $NAME 32 | } 33 | 34 | [[ ! -n $1 ]] && { echo "usage: ./make all && ./soakbean.com"; exit 0; } 35 | "$@" 36 | -------------------------------------------------------------------------------- /middleware/blacklisturl.lua: -------------------------------------------------------------------------------- 1 | -- blocks certain url patterns 2 | -- usage: 3 | -- app.use( require("middleware/blacklisturl")({"^/foo","/^bar"}) ) 4 | -- 5 | return function(urls) 6 | return function(req,res,next) 7 | for k,url in pairs(urls) do 8 | if req.url:match(url) then return SetStatus(403) end 9 | end 10 | next() 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /middleware/json.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- json.lua 3 | -- 4 | -- Copyright (c) 2020 rxi 5 | -- 6 | -- Permission is hereby granted, free of charge, to any person obtaining a copy of 7 | -- this software and associated documentation files (the "Software"), to deal in 8 | -- the Software without restriction, including without limitation the rights to 9 | -- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 10 | -- of the Software, and to permit persons to whom the Software is furnished to do 11 | -- so, subject to the following conditions: 12 | -- 13 | -- The above copyright notice and this permission notice shall be included in all 14 | -- copies or substantial portions of the Software. 15 | -- 16 | -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | -- SOFTWARE. 23 | -- 24 | 25 | local json = { _version = "0.1.2" } 26 | 27 | ------------------------------------------------------------------------------- 28 | -- Encode 29 | ------------------------------------------------------------------------------- 30 | 31 | local encode 32 | 33 | local escape_char_map = { 34 | [ "\\" ] = "\\", 35 | [ "\"" ] = "\"", 36 | [ "\b" ] = "b", 37 | [ "\f" ] = "f", 38 | [ "\n" ] = "n", 39 | [ "\r" ] = "r", 40 | [ "\t" ] = "t", 41 | } 42 | 43 | local escape_char_map_inv = { [ "/" ] = "/" } 44 | for k, v in pairs(escape_char_map) do 45 | escape_char_map_inv[v] = k 46 | end 47 | 48 | 49 | local function escape_char(c) 50 | return "\\" .. (escape_char_map[c] or string.format("u%04x", c:byte())) 51 | end 52 | 53 | 54 | local function encode_nil(val) 55 | return "null" 56 | end 57 | 58 | 59 | local function encode_table(val, stack) 60 | local res = {} 61 | stack = stack or {} 62 | 63 | -- Circular reference? 64 | if stack[val] then error("circular reference") end 65 | 66 | stack[val] = true 67 | 68 | if rawget(val, 1) ~= nil or next(val) == nil then 69 | -- Treat as array -- check keys are valid and it is not sparse 70 | local n = 0 71 | for k in pairs(val) do 72 | if type(k) ~= "number" then 73 | error("invalid table: mixed or invalid key types") 74 | end 75 | n = n + 1 76 | end 77 | if n ~= #val then 78 | error("invalid table: sparse array") 79 | end 80 | -- Encode 81 | for i, v in ipairs(val) do 82 | table.insert(res, encode(v, stack)) 83 | end 84 | stack[val] = nil 85 | return "[" .. table.concat(res, ",") .. "]" 86 | 87 | else 88 | -- Treat as an object 89 | for k, v in pairs(val) do 90 | if type(k) ~= "string" then 91 | error("invalid table: mixed or invalid key types") 92 | end 93 | table.insert(res, encode(k, stack) .. ":" .. encode(v, stack)) 94 | end 95 | stack[val] = nil 96 | return "{" .. table.concat(res, ",") .. "}" 97 | end 98 | end 99 | 100 | 101 | local function encode_string(val) 102 | return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"' 103 | end 104 | 105 | 106 | local function encode_number(val) 107 | -- Check for NaN, -inf and inf 108 | if val ~= val or val <= -math.huge or val >= math.huge then 109 | error("unexpected number value '" .. tostring(val) .. "'") 110 | end 111 | return string.format("%.14g", val) 112 | end 113 | 114 | 115 | local type_func_map = { 116 | [ "nil" ] = encode_nil, 117 | [ "table" ] = encode_table, 118 | [ "string" ] = encode_string, 119 | [ "number" ] = encode_number, 120 | [ "boolean" ] = tostring, 121 | } 122 | 123 | 124 | encode = function(val, stack) 125 | local t = type(val) 126 | local f = type_func_map[t] 127 | if f then 128 | return f(val, stack) 129 | end 130 | error("unexpected type '" .. t .. "'") 131 | end 132 | 133 | 134 | function json.encode(val) 135 | return ( encode(val) ) 136 | end 137 | 138 | 139 | ------------------------------------------------------------------------------- 140 | -- Decode 141 | ------------------------------------------------------------------------------- 142 | 143 | local parse 144 | 145 | local function create_set(...) 146 | local res = {} 147 | for i = 1, select("#", ...) do 148 | res[ select(i, ...) ] = true 149 | end 150 | return res 151 | end 152 | 153 | local space_chars = create_set(" ", "\t", "\r", "\n") 154 | local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",") 155 | local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u") 156 | local literals = create_set("true", "false", "null") 157 | 158 | local literal_map = { 159 | [ "true" ] = true, 160 | [ "false" ] = false, 161 | [ "null" ] = nil, 162 | } 163 | 164 | 165 | local function next_char(str, idx, set, negate) 166 | for i = idx, #str do 167 | if set[str:sub(i, i)] ~= negate then 168 | return i 169 | end 170 | end 171 | return #str + 1 172 | end 173 | 174 | 175 | local function decode_error(str, idx, msg) 176 | local line_count = 1 177 | local col_count = 1 178 | for i = 1, idx - 1 do 179 | col_count = col_count + 1 180 | if str:sub(i, i) == "\n" then 181 | line_count = line_count + 1 182 | col_count = 1 183 | end 184 | end 185 | error( string.format("%s at line %d col %d", msg, line_count, col_count) ) 186 | end 187 | 188 | 189 | local function codepoint_to_utf8(n) 190 | -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa 191 | local f = math.floor 192 | if n <= 0x7f then 193 | return string.char(n) 194 | elseif n <= 0x7ff then 195 | return string.char(f(n / 64) + 192, n % 64 + 128) 196 | elseif n <= 0xffff then 197 | return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128) 198 | elseif n <= 0x10ffff then 199 | return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128, 200 | f(n % 4096 / 64) + 128, n % 64 + 128) 201 | end 202 | error( string.format("invalid unicode codepoint '%x'", n) ) 203 | end 204 | 205 | 206 | local function parse_unicode_escape(s) 207 | local n1 = tonumber( s:sub(1, 4), 16 ) 208 | local n2 = tonumber( s:sub(7, 10), 16 ) 209 | -- Surrogate pair? 210 | if n2 then 211 | return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000) 212 | else 213 | return codepoint_to_utf8(n1) 214 | end 215 | end 216 | 217 | 218 | local function parse_string(str, i) 219 | local res = "" 220 | local j = i + 1 221 | local k = j 222 | 223 | while j <= #str do 224 | local x = str:byte(j) 225 | 226 | if x < 32 then 227 | decode_error(str, j, "control character in string") 228 | 229 | elseif x == 92 then -- `\`: Escape 230 | res = res .. str:sub(k, j - 1) 231 | j = j + 1 232 | local c = str:sub(j, j) 233 | if c == "u" then 234 | local hex = str:match("^[dD][89aAbB]%x%x\\u%x%x%x%x", j + 1) 235 | or str:match("^%x%x%x%x", j + 1) 236 | or decode_error(str, j - 1, "invalid unicode escape in string") 237 | res = res .. parse_unicode_escape(hex) 238 | j = j + #hex 239 | else 240 | if not escape_chars[c] then 241 | decode_error(str, j - 1, "invalid escape char '" .. c .. "' in string") 242 | end 243 | res = res .. escape_char_map_inv[c] 244 | end 245 | k = j + 1 246 | 247 | elseif x == 34 then -- `"`: End of string 248 | res = res .. str:sub(k, j - 1) 249 | return res, j + 1 250 | end 251 | 252 | j = j + 1 253 | end 254 | 255 | decode_error(str, i, "expected closing quote for string") 256 | end 257 | 258 | 259 | local function parse_number(str, i) 260 | local x = next_char(str, i, delim_chars) 261 | local s = str:sub(i, x - 1) 262 | local n = tonumber(s) 263 | if not n then 264 | decode_error(str, i, "invalid number '" .. s .. "'") 265 | end 266 | return n, x 267 | end 268 | 269 | 270 | local function parse_literal(str, i) 271 | local x = next_char(str, i, delim_chars) 272 | local word = str:sub(i, x - 1) 273 | if not literals[word] then 274 | decode_error(str, i, "invalid literal '" .. word .. "'") 275 | end 276 | return literal_map[word], x 277 | end 278 | 279 | 280 | local function parse_array(str, i) 281 | local res = {} 282 | local n = 1 283 | i = i + 1 284 | while 1 do 285 | local x 286 | i = next_char(str, i, space_chars, true) 287 | -- Empty / end of array? 288 | if str:sub(i, i) == "]" then 289 | i = i + 1 290 | break 291 | end 292 | -- Read token 293 | x, i = parse(str, i) 294 | res[n] = x 295 | n = n + 1 296 | -- Next token 297 | i = next_char(str, i, space_chars, true) 298 | local chr = str:sub(i, i) 299 | i = i + 1 300 | if chr == "]" then break end 301 | if chr ~= "," then decode_error(str, i, "expected ']' or ','") end 302 | end 303 | return res, i 304 | end 305 | 306 | 307 | local function parse_object(str, i) 308 | local res = {} 309 | i = i + 1 310 | while 1 do 311 | local key, val 312 | i = next_char(str, i, space_chars, true) 313 | -- Empty / end of object? 314 | if str:sub(i, i) == "}" then 315 | i = i + 1 316 | break 317 | end 318 | -- Read key 319 | if str:sub(i, i) ~= '"' then 320 | decode_error(str, i, "expected string for key") 321 | end 322 | key, i = parse(str, i) 323 | -- Read ':' delimiter 324 | i = next_char(str, i, space_chars, true) 325 | if str:sub(i, i) ~= ":" then 326 | decode_error(str, i, "expected ':' after key") 327 | end 328 | i = next_char(str, i + 1, space_chars, true) 329 | -- Read value 330 | val, i = parse(str, i) 331 | -- Set 332 | res[key] = val 333 | -- Next token 334 | i = next_char(str, i, space_chars, true) 335 | local chr = str:sub(i, i) 336 | i = i + 1 337 | if chr == "}" then break end 338 | if chr ~= "," then decode_error(str, i, "expected '}' or ','") end 339 | end 340 | return res, i 341 | end 342 | 343 | 344 | local char_func_map = { 345 | [ '"' ] = parse_string, 346 | [ "0" ] = parse_number, 347 | [ "1" ] = parse_number, 348 | [ "2" ] = parse_number, 349 | [ "3" ] = parse_number, 350 | [ "4" ] = parse_number, 351 | [ "5" ] = parse_number, 352 | [ "6" ] = parse_number, 353 | [ "7" ] = parse_number, 354 | [ "8" ] = parse_number, 355 | [ "9" ] = parse_number, 356 | [ "-" ] = parse_number, 357 | [ "t" ] = parse_literal, 358 | [ "f" ] = parse_literal, 359 | [ "n" ] = parse_literal, 360 | [ "[" ] = parse_array, 361 | [ "{" ] = parse_object, 362 | } 363 | 364 | 365 | parse = function(str, idx) 366 | local chr = str:sub(idx, idx) 367 | local f = char_func_map[chr] 368 | if f then 369 | return f(str, idx) 370 | end 371 | decode_error(str, idx, "unexpected character '" .. chr .. "'") 372 | end 373 | 374 | 375 | function json.decode(str) 376 | if type(str) ~= "string" then 377 | error("expected argument of type string, got " .. type(str)) 378 | end 379 | local res, idx = parse(str, next_char(str, 1, space_chars, true)) 380 | idx = next_char(str, idx, space_chars, true) 381 | if idx <= #str then 382 | decode_error(str, idx, "trailing garbage") 383 | end 384 | return res 385 | end 386 | 387 | function json.middleware() 388 | local json_response = function(response) 389 | return function() 390 | return function(req,res,next) 391 | if type(res._body) == "table" then 392 | res.header('content-type',"application/json") 393 | res.body( json.encode(res._body) ) 394 | end 395 | response(req,res,next) 396 | end 397 | end 398 | end 399 | sb.response = json_response(sb.response()) 400 | return function(req,res,next) 401 | if req.method ~= "GET" and req.header['Content-Type']:match("application/json") and GetPayload():sub(0,1) == "{" then 402 | req.body = json.decode( GetPayload() ) 403 | end 404 | next() 405 | end 406 | end 407 | 408 | return json 409 | -------------------------------------------------------------------------------- /soakbean.com: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coderofsalvation/soakbean/d669e21e1afcc3cf69cb93661ad74515e24d8601/soakbean.com -------------------------------------------------------------------------------- /src/.init.lua: -------------------------------------------------------------------------------- 1 | package.path = package.path .. ";.lua/?.lua" 2 | package.path = package.path .. ";src/.lua/?.lua" 3 | package.path = package.path .. ";middleware/?.lua" 4 | 5 | json = require "json" 6 | 7 | -- special script called by main redbean process at startup 8 | HidePath('/usr/share/zoneinfo/') 9 | HidePath('/usr/share/ssl/') 10 | 11 | -- create 12 | app = require("soakbean") { 13 | bin = "./redbean.com", 14 | opts = { 15 | my_cli_arg=1 16 | }, 17 | cmd={ 18 | -- runtask = {file="sometask.lua", info="description of cli cmd"} 19 | }, 20 | title = 'SOAKBEAN - a buddy of redbean', 21 | subtitle = 'SOAKBEAN makes redbean programming easy', 22 | notes = {'🤩 express-style programming', '🖧 easy routings', '♻ re-use middleware functions'} 23 | } 24 | 25 | app.url['^/data'] = '/data.lua' -- setup custom file endpoint 26 | 27 | app.get('^/', app.template('index.html') ) -- alias for app.tpl( LoadAsset('index.html'), app ) 28 | 29 | app.post('^/save', function(req,res,next) -- setup inline POST endpoint 30 | -- also .get(), .put(), .delete(), .options() 31 | app.cache = req.body -- middleware auto-decodes json 32 | res.status(200) 33 | res.body({cache=app.cache}) -- middleware auto-encodes json 34 | next() 35 | end) 36 | 37 | app -- 38 | .use( require("json").middleware() ) -- try plug'n'play json API middleware 39 | .use( app.router( app.url ) ) -- try url router 40 | .use( app.response() ) -- try serve app response (if any) 41 | .use( function(req,next) Route() end) -- fallback default redbean fileserver 42 | 43 | function OnHttpRequest() app.run() end 44 | -------------------------------------------------------------------------------- /src/.lua/json.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- json.lua 3 | -- 4 | -- Copyright (c) 2020 rxi 5 | -- 6 | -- Permission is hereby granted, free of charge, to any person obtaining a copy of 7 | -- this software and associated documentation files (the "Software"), to deal in 8 | -- the Software without restriction, including without limitation the rights to 9 | -- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 10 | -- of the Software, and to permit persons to whom the Software is furnished to do 11 | -- so, subject to the following conditions: 12 | -- 13 | -- The above copyright notice and this permission notice shall be included in all 14 | -- copies or substantial portions of the Software. 15 | -- 16 | -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | -- SOFTWARE. 23 | -- 24 | 25 | local json = { _version = "0.1.2" } 26 | 27 | ------------------------------------------------------------------------------- 28 | -- Encode 29 | ------------------------------------------------------------------------------- 30 | 31 | local encode 32 | 33 | local escape_char_map = { 34 | [ "\\" ] = "\\", 35 | [ "\"" ] = "\"", 36 | [ "\b" ] = "b", 37 | [ "\f" ] = "f", 38 | [ "\n" ] = "n", 39 | [ "\r" ] = "r", 40 | [ "\t" ] = "t", 41 | } 42 | 43 | local escape_char_map_inv = { [ "/" ] = "/" } 44 | for k, v in pairs(escape_char_map) do 45 | escape_char_map_inv[v] = k 46 | end 47 | 48 | 49 | local function escape_char(c) 50 | return "\\" .. (escape_char_map[c] or string.format("u%04x", c:byte())) 51 | end 52 | 53 | 54 | local function encode_nil(val) 55 | return "null" 56 | end 57 | 58 | 59 | local function encode_table(val, stack) 60 | local res = {} 61 | stack = stack or {} 62 | 63 | -- Circular reference? 64 | if stack[val] then error("circular reference") end 65 | 66 | stack[val] = true 67 | 68 | if rawget(val, 1) ~= nil or next(val) == nil then 69 | -- Treat as array -- check keys are valid and it is not sparse 70 | local n = 0 71 | for k in pairs(val) do 72 | if type(k) ~= "number" then 73 | error("invalid table: mixed or invalid key types") 74 | end 75 | n = n + 1 76 | end 77 | if n ~= #val then 78 | error("invalid table: sparse array") 79 | end 80 | -- Encode 81 | for i, v in ipairs(val) do 82 | table.insert(res, encode(v, stack)) 83 | end 84 | stack[val] = nil 85 | return "[" .. table.concat(res, ",") .. "]" 86 | 87 | else 88 | -- Treat as an object 89 | for k, v in pairs(val) do 90 | if type(k) ~= "string" then 91 | error("invalid table: mixed or invalid key types") 92 | end 93 | table.insert(res, encode(k, stack) .. ":" .. encode(v, stack)) 94 | end 95 | stack[val] = nil 96 | return "{" .. table.concat(res, ",") .. "}" 97 | end 98 | end 99 | 100 | 101 | local function encode_string(val) 102 | return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"' 103 | end 104 | 105 | 106 | local function encode_number(val) 107 | -- Check for NaN, -inf and inf 108 | if val ~= val or val <= -math.huge or val >= math.huge then 109 | error("unexpected number value '" .. tostring(val) .. "'") 110 | end 111 | return string.format("%.14g", val) 112 | end 113 | 114 | 115 | local type_func_map = { 116 | [ "nil" ] = encode_nil, 117 | [ "table" ] = encode_table, 118 | [ "string" ] = encode_string, 119 | [ "number" ] = encode_number, 120 | [ "boolean" ] = tostring, 121 | } 122 | 123 | 124 | encode = function(val, stack) 125 | local t = type(val) 126 | local f = type_func_map[t] 127 | if f then 128 | return f(val, stack) 129 | end 130 | error("unexpected type '" .. t .. "'") 131 | end 132 | 133 | 134 | function json.encode(val) 135 | return ( encode(val) ) 136 | end 137 | 138 | 139 | ------------------------------------------------------------------------------- 140 | -- Decode 141 | ------------------------------------------------------------------------------- 142 | 143 | local parse 144 | 145 | local function create_set(...) 146 | local res = {} 147 | for i = 1, select("#", ...) do 148 | res[ select(i, ...) ] = true 149 | end 150 | return res 151 | end 152 | 153 | local space_chars = create_set(" ", "\t", "\r", "\n") 154 | local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",") 155 | local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u") 156 | local literals = create_set("true", "false", "null") 157 | 158 | local literal_map = { 159 | [ "true" ] = true, 160 | [ "false" ] = false, 161 | [ "null" ] = nil, 162 | } 163 | 164 | 165 | local function next_char(str, idx, set, negate) 166 | for i = idx, #str do 167 | if set[str:sub(i, i)] ~= negate then 168 | return i 169 | end 170 | end 171 | return #str + 1 172 | end 173 | 174 | 175 | local function decode_error(str, idx, msg) 176 | local line_count = 1 177 | local col_count = 1 178 | for i = 1, idx - 1 do 179 | col_count = col_count + 1 180 | if str:sub(i, i) == "\n" then 181 | line_count = line_count + 1 182 | col_count = 1 183 | end 184 | end 185 | error( string.format("%s at line %d col %d", msg, line_count, col_count) ) 186 | end 187 | 188 | 189 | local function codepoint_to_utf8(n) 190 | -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa 191 | local f = math.floor 192 | if n <= 0x7f then 193 | return string.char(n) 194 | elseif n <= 0x7ff then 195 | return string.char(f(n / 64) + 192, n % 64 + 128) 196 | elseif n <= 0xffff then 197 | return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128) 198 | elseif n <= 0x10ffff then 199 | return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128, 200 | f(n % 4096 / 64) + 128, n % 64 + 128) 201 | end 202 | error( string.format("invalid unicode codepoint '%x'", n) ) 203 | end 204 | 205 | 206 | local function parse_unicode_escape(s) 207 | local n1 = tonumber( s:sub(1, 4), 16 ) 208 | local n2 = tonumber( s:sub(7, 10), 16 ) 209 | -- Surrogate pair? 210 | if n2 then 211 | return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000) 212 | else 213 | return codepoint_to_utf8(n1) 214 | end 215 | end 216 | 217 | 218 | local function parse_string(str, i) 219 | local res = "" 220 | local j = i + 1 221 | local k = j 222 | 223 | while j <= #str do 224 | local x = str:byte(j) 225 | 226 | if x < 32 then 227 | decode_error(str, j, "control character in string") 228 | 229 | elseif x == 92 then -- `\`: Escape 230 | res = res .. str:sub(k, j - 1) 231 | j = j + 1 232 | local c = str:sub(j, j) 233 | if c == "u" then 234 | local hex = str:match("^[dD][89aAbB]%x%x\\u%x%x%x%x", j + 1) 235 | or str:match("^%x%x%x%x", j + 1) 236 | or decode_error(str, j - 1, "invalid unicode escape in string") 237 | res = res .. parse_unicode_escape(hex) 238 | j = j + #hex 239 | else 240 | if not escape_chars[c] then 241 | decode_error(str, j - 1, "invalid escape char '" .. c .. "' in string") 242 | end 243 | res = res .. escape_char_map_inv[c] 244 | end 245 | k = j + 1 246 | 247 | elseif x == 34 then -- `"`: End of string 248 | res = res .. str:sub(k, j - 1) 249 | return res, j + 1 250 | end 251 | 252 | j = j + 1 253 | end 254 | 255 | decode_error(str, i, "expected closing quote for string") 256 | end 257 | 258 | 259 | local function parse_number(str, i) 260 | local x = next_char(str, i, delim_chars) 261 | local s = str:sub(i, x - 1) 262 | local n = tonumber(s) 263 | if not n then 264 | decode_error(str, i, "invalid number '" .. s .. "'") 265 | end 266 | return n, x 267 | end 268 | 269 | 270 | local function parse_literal(str, i) 271 | local x = next_char(str, i, delim_chars) 272 | local word = str:sub(i, x - 1) 273 | if not literals[word] then 274 | decode_error(str, i, "invalid literal '" .. word .. "'") 275 | end 276 | return literal_map[word], x 277 | end 278 | 279 | 280 | local function parse_array(str, i) 281 | local res = {} 282 | local n = 1 283 | i = i + 1 284 | while 1 do 285 | local x 286 | i = next_char(str, i, space_chars, true) 287 | -- Empty / end of array? 288 | if str:sub(i, i) == "]" then 289 | i = i + 1 290 | break 291 | end 292 | -- Read token 293 | x, i = parse(str, i) 294 | res[n] = x 295 | n = n + 1 296 | -- Next token 297 | i = next_char(str, i, space_chars, true) 298 | local chr = str:sub(i, i) 299 | i = i + 1 300 | if chr == "]" then break end 301 | if chr ~= "," then decode_error(str, i, "expected ']' or ','") end 302 | end 303 | return res, i 304 | end 305 | 306 | 307 | local function parse_object(str, i) 308 | local res = {} 309 | i = i + 1 310 | while 1 do 311 | local key, val 312 | i = next_char(str, i, space_chars, true) 313 | -- Empty / end of object? 314 | if str:sub(i, i) == "}" then 315 | i = i + 1 316 | break 317 | end 318 | -- Read key 319 | if str:sub(i, i) ~= '"' then 320 | decode_error(str, i, "expected string for key") 321 | end 322 | key, i = parse(str, i) 323 | -- Read ':' delimiter 324 | i = next_char(str, i, space_chars, true) 325 | if str:sub(i, i) ~= ":" then 326 | decode_error(str, i, "expected ':' after key") 327 | end 328 | i = next_char(str, i + 1, space_chars, true) 329 | -- Read value 330 | val, i = parse(str, i) 331 | -- Set 332 | res[key] = val 333 | -- Next token 334 | i = next_char(str, i, space_chars, true) 335 | local chr = str:sub(i, i) 336 | i = i + 1 337 | if chr == "}" then break end 338 | if chr ~= "," then decode_error(str, i, "expected '}' or ','") end 339 | end 340 | return res, i 341 | end 342 | 343 | 344 | local char_func_map = { 345 | [ '"' ] = parse_string, 346 | [ "0" ] = parse_number, 347 | [ "1" ] = parse_number, 348 | [ "2" ] = parse_number, 349 | [ "3" ] = parse_number, 350 | [ "4" ] = parse_number, 351 | [ "5" ] = parse_number, 352 | [ "6" ] = parse_number, 353 | [ "7" ] = parse_number, 354 | [ "8" ] = parse_number, 355 | [ "9" ] = parse_number, 356 | [ "-" ] = parse_number, 357 | [ "t" ] = parse_literal, 358 | [ "f" ] = parse_literal, 359 | [ "n" ] = parse_literal, 360 | [ "[" ] = parse_array, 361 | [ "{" ] = parse_object, 362 | } 363 | 364 | 365 | parse = function(str, idx) 366 | local chr = str:sub(idx, idx) 367 | local f = char_func_map[chr] 368 | if f then 369 | return f(str, idx) 370 | end 371 | decode_error(str, idx, "unexpected character '" .. chr .. "'") 372 | end 373 | 374 | 375 | function json.decode(str) 376 | if type(str) ~= "string" then 377 | error("expected argument of type string, got " .. type(str)) 378 | end 379 | local res, idx = parse(str, next_char(str, 1, space_chars, true)) 380 | idx = next_char(str, idx, space_chars, true) 381 | if idx <= #str then 382 | decode_error(str, idx, "trailing garbage") 383 | end 384 | return res 385 | end 386 | 387 | function json.middleware() 388 | local json_response = function(response) 389 | return function() 390 | return function(req,res,next) 391 | if type(res._body) == "table" then 392 | res.header('content-type',"application/json") 393 | res.body( json.encode(res._body) ) 394 | end 395 | response(req,res,next) 396 | end 397 | end 398 | end 399 | sb.response = json_response(sb.response()) 400 | return function(req,res,next) 401 | if req.method ~= "GET" and req.header['Content-Type']:match("application/json") and GetPayload():sub(0,1) == "{" then 402 | req.body = json.decode( GetPayload() ) 403 | end 404 | next() 405 | end 406 | end 407 | 408 | return json 409 | -------------------------------------------------------------------------------- /src/.lua/soakbean.lua: -------------------------------------------------------------------------------- 1 | local json = require "json" 2 | 3 | function error(str) print("[error] " .. str) end 4 | 5 | function keys(table) local i = 0 ; for k, v in pairs(table) do i = i + 1 end ; return i end 6 | 7 | sb = {} 8 | sb = { 9 | 10 | middleware={}, 11 | handler={}, 12 | data={}, 13 | charset="utf-8", 14 | 15 | __index=function(self,k,v) 16 | if sb.data[k] then return sb.data[k] end 17 | if sb[k] then return sb[k] end 18 | return rawget(self,k) 19 | end, 20 | 21 | __newindex=function(self,k,v) 22 | if type(v) == "function" then 23 | sb.data[k] = (function(k,v) 24 | return function(a,b,c,d,e,f) 25 | v(a,b,c,d,e,f) 26 | sb.pub(k,v) 27 | end 28 | end)(k,v) 29 | else 30 | sb.data[k] = v 31 | sb.pub(k,v) 32 | end 33 | end, 34 | 35 | on = function(k,f) 36 | sb.handler[k] = sb.handler[k] or {} 37 | table.insert( sb.handler[k], f ) 38 | return sb.app 39 | end, 40 | 41 | pub = function(k,v) 42 | if sb.handler[k] ~= nil then 43 | for i,handler in pairs(sb.handler[k]) do 44 | coroutine.wrap(handler)(v,k) 45 | end 46 | end 47 | end, 48 | 49 | -- print( util.tpl("${name} is ${value}", {name = "foo", value = "bar"}) ) 50 | -- "foo is bar" 51 | tpl = function(s,tab) 52 | return (s:gsub('($%b{})', function(w) return tab[w:sub(3, -2)] or w end)) 53 | end, 54 | 55 | write = function(str) 56 | Write( sb.app.tpl(str,sb.app.data) ) 57 | end, 58 | 59 | job = function(app) 60 | return function(cfg) 61 | -- app. 62 | return app 63 | end 64 | end, 65 | 66 | request = function(method, app) 67 | return function(path,f) 68 | app.use( function(req,res,next) 69 | if req.url:match(path) and req.method == method then 70 | f(req,res,next) 71 | else next() end 72 | end) 73 | end 74 | end, 75 | 76 | use = function(f) 77 | table.insert(sb.middleware,f) 78 | return sb.app 79 | end, 80 | 81 | useDefaults = function(app) 82 | app.use( function(req,res,next) 83 | app.pub(req.url,{req=req,res=res}) 84 | next() 85 | end) 86 | end, 87 | 88 | run = function(req) 89 | local next = function() end 90 | local k = 0 91 | local req = { 92 | param={}, 93 | method=GetMethod(), 94 | host=GetHost(), 95 | header=GetHeaders(), 96 | url=GetPath(), 97 | protocol=GetScheme(), 98 | body={} 99 | } 100 | local res={ 101 | _status=nil, 102 | _header={}, 103 | _body="" 104 | } 105 | local params = GetParams() 106 | if params ~= nil then 107 | for i,p in pairs(params) do req.param[ p[1] ] = p[2] end 108 | end 109 | res.status = function(status) res._status = status ; sb.pub('res.status',status) end 110 | res.body = function(v) res._body = v ; sb.pub('req.body' ,body) end 111 | res.header = function(k,v) 112 | if v == nil then return res._header[k] end 113 | res._header[k] = v 114 | sb.pub('res.header',{key=k,value=v}) 115 | end 116 | res.header("content-type","text/html") 117 | -- run middleware 118 | next = function() 119 | k = k+1 120 | if type(sb.middleware[k]) == "function" then sb.middleware[k](req,res,next) end 121 | end 122 | next() 123 | end, 124 | 125 | json = function() 126 | local json_response = function(response) 127 | return function() 128 | return function(req,res,next) 129 | if type(res._body) == "table" then 130 | res.header('content-type',"application/json") 131 | res.body( json.encode(res._body) ) 132 | end 133 | response(req,res,next) 134 | end 135 | end 136 | end 137 | sb.response = json_response(sb.response()) 138 | return function(req,res,next) 139 | if req.method ~= "GET" and req.header['Content-Type']:match("application/json") and GetPayload():sub(0,1) == "{" then 140 | req.body = json.decode( GetPayload() ) 141 | end 142 | next() 143 | end 144 | end, 145 | 146 | response = function() 147 | return function(req,res,next) 148 | if res._body ~= nil and res._status ~= nil and res._header['content-type'] ~= nil then 149 | SetStatus(res._status) 150 | for k,v in pairs(res._header) do 151 | if k == "content-type" then v = v .. "; charset=" .. sb.charset end 152 | SetHeader(k,v) 153 | end 154 | if type(res._body) == "string" then 155 | Write( res._body ) 156 | else print("[ERROR] res.body is not a string (HINT: use json middleware)") end 157 | else next() end 158 | end 159 | end, 160 | 161 | router = function(router) 162 | return function(req,res,next) 163 | for p1, p2 in pairs(router) do 164 | if GetPath():match(p1) then 165 | if type(p2) == "string" then 166 | print("router: " .. p1 .. " => " .. p2) 167 | RoutePath(p2) 168 | end 169 | if type(p2) == "function" then p2(req,res,next) end 170 | sb.pub(p1,req) 171 | sb.pub(p2,req) 172 | return true 173 | end 174 | end 175 | next() 176 | end 177 | end, 178 | 179 | template = function(file) 180 | return function(req,res,next) 181 | res.status(200) 182 | res.header('content-type','text/html') 183 | res.body( sb.app.tpl( LoadAsset(file), sb.app ) ) 184 | next() 185 | end 186 | end, 187 | 188 | init = function(app) 189 | for k,v in pairs(argv) do 190 | app.opts[ v:gsub("=.*","") ] = v:gsub(".*=","") 191 | end 192 | if( keys(app.cmd) > 0 ) then sb.runcmd(app) end 193 | end, 194 | 195 | runcmd = function(app) 196 | for k,v in pairs(app.opts) do 197 | if app.cmd[k] then 198 | local file = app.cmd[k].file 199 | return require( file:sub(0,-5) )(app,argv) 200 | end 201 | end 202 | print("\nUsage: " .. app.bin .. " [opts]\n\n") 203 | for k,v in pairs(app.cmd) do 204 | print("\t" .. app.bin .. " " .. k .. "\t\t" .. v.info ) 205 | end 206 | print("") 207 | os.exit() 208 | end 209 | 210 | } 211 | 212 | return function(data) 213 | local app = {} 214 | setmetatable(app,sb) 215 | sb.app = app 216 | sb.data = data 217 | sb.data.url = {} 218 | if data.url == nil then sb.data.url = {} end 219 | sb.get = sb.request('GET', app) 220 | sb.post = sb.request('POST', app) 221 | sb.put = sb.request('PUT', app) 222 | sb.options = sb.request('OPTIONS', app) 223 | sb.delete = sb.request('DELETE', app) 224 | sb.init(app) 225 | sb.useDefaults(app) 226 | return app 227 | end 228 | -------------------------------------------------------------------------------- /src/data.lua: -------------------------------------------------------------------------------- 1 | -- most usecases will just do fine with the global soakbean app in .init.lua 2 | -- however, file-endpoints can initialize separated soakbean apps 3 | -- or just use direct Redbean calls. 4 | local json = require("json") 5 | local app2 = require("soakbean") -- in theory you could setup a separate instance here 6 | 7 | SetStatus(200) 8 | SetHeader('Content-Type', 'application/json; charset=utf-8') 9 | local data = {} 10 | for k,v in pairs({"notes","title"}) do data[v] = app[v] end 11 | Write( json.encode( data ) ) 12 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ${title} 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 |
13 |
14 | 15 |
16 |

17 |
${title}
18 |
19 | 22 |
23 | Read the documentation here. 24 |
25 |
26 |
27 |
28 |
29 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/tests.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | soakbean tests 4 | 5 | 6 |

 7 |     
76 |   
77 | 
78 | 


--------------------------------------------------------------------------------