Web and data

Fubby includes a web server, a database and background tasks. You don't import or install any of them.

Snow: web apps

A Snow app is ordinary Fubby. main registers routes at startup, then Snow serves requests. A handler is a function with no parameters and no return value, and you register it by name. Start an app with fubby snow serve, because fubby run doesn't start a server.

notes.fab
func main() {
    let db = moon.db.sqlite.open("notes.db", {})
    db.execute("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, title TEXT NOT NULL)", [])

    snow.get("/", "home")
    snow.get("/hello/:name", "hello")
    snow.get("/api/notes", "list_notes")
    snow.post("/api/notes", "create_note")
    snow.delete("/api/notes/:id", "delete_note")
    snow.before("prepare")
    snow.after("add_headers")
    snow.on_error("failed")
}

func prepare() {
    snow.request()["state"].set("app", "Notes")
}

func add_headers() {
    snow.header("X-App", snow.request()["state"]["app"])
}

func failed() {
    snow.json({"error": "Something went wrong"}, 500)
}

func home() {
    snow.html("<h1>Notes</h1><p>See <a href=\"/api/notes\">the API</a>.</p>")
}

func hello() {
    let name = snow.request()["params"]["name"]
    snow.html(snow.template("<h1>Hello, {{name}}</h1>", {"name": name}))
}

func list_notes() {
    let db = moon.db.sqlite.open("notes.db", {})
    snow.json(db.query("SELECT id, title FROM notes ORDER BY id", []))
}

func create_note() {
    try {
        let title = json_decode(snow.request()["body"])["title"]
        let db = moon.db.sqlite.open("notes.db", {})
        let saved = db.execute("INSERT INTO notes (title) VALUES (?1)", [title])
        snow.json({"id": saved["last_insert_rowid"], "title": title}, 201)
    } catch (error) {
        snow.json({"error": "Send JSON with a title"}, 400)
    }
}

func delete_note() {
    let id = snow.request()["params"]["id"]
    let db = moon.db.sqlite.open("notes.db", {})
    db.execute("DELETE FROM notes WHERE id = ?1", [id])
    snow.text("", 204)
}

Run it, and try the API from another terminal:

fubby snow serve notes.fab --port 8080

curl -X POST localhost:8080/api/notes -d '{"title": "First note"}'
curl localhost:8080/api/notes

Requests

snow.request() returns a map:

KeyValue
methodThe HTTP method
pathThe decoded path, with no query string
queryA map of query values. Repeated keys are rejected
headersA map, with lowercase header names
paramsRoute parameters, such as id from /notes/:id, as strings
bodyThe body as text. Decode JSON yourself with json_decode
stateAn empty map for your middleware and handlers to share during this request

Responses

Use snow.html, snow.text or snow.json, each with an optional status code. snow.header sets a header, and snow.status sets the status. A handler must produce a response.

snow.template fills {{name}} placeholders, and escapes values for HTML. Use it for any value that comes from a user. It protects HTML text and quoted attributes, but not script, style or URL contexts.

Middleware and errors

snow.before handlers run in the order you registered them, and a response from one skips the rest and the route. snow.after handlers run afterwards, in reverse order. If a handler fails, the partial response is discarded, and the snow.on_error handler runs. snow.error() gives it the message, which you shouldn't show to users.

Static files

snow.static("/assets", "assets") serves a folder, resolved relative to your .fab file. Routes you register take priority, and folders are never listed.

Server options

OptionDefaultRange
--host127.0.0.1An IP address, or localhost
--port80800 to 65535
--workers11 to 8. The default handles one request at a time
--queue-capacity161 to 64. When full, new connections get a 503 response
--handler-timeout-ms10001 to 30000
--read-timeout-ms50001 to 30000
--write-timeout-ms50001 to 30000
--max-body-bytes65536Up to 1 MiB
--max-response-bytes1048576Up to 10 MiB
--max-requestsNoneStop after this many connections
--shutdown-fileNoneA file path that doesn't exist yet. Used to stop the server cleanly
--quietOffTurns off request logging

To stop a server cleanly, start it with --shutdown-file /tmp/app.stop, then run fubby snow stop /tmp/app.stop. Ctrl-C also stops it, but it can interrupt a request.

Snow is for local development. It has no HTTPS, login, sessions, CSRF protection or rate limiting, and it doesn't support uploads through multipart forms. Keep it on your own machine.

Moon: SQLite

SQLite is built in. The second argument to open is a map of options, and an empty map is fine. Always pass values through ?1-style parameters, which keeps them separate from your SQL and prevents SQL injection.

func main() {
    let db = moon.db.sqlite.open(":memory:", {})
    db.execute("CREATE TABLE people (name TEXT, age INTEGER)", [])
    let saved = db.execute("INSERT INTO people VALUES (?1, ?2)", ["Ada", 36])
    print(saved)
    db.execute("INSERT INTO people VALUES (?1, ?2)", ["Asha", null])

    let rows = db.query("SELECT name, age FROM people ORDER BY name", [])
    print(rows[0]["name"], rows[0]["age"])
    print(rows[1]["age"] == null)
}
{changes: 1, last_insert_rowid: 1}
Ada 36
true

Use a file name for a database that persists. Options are timeout_ms (default 1 second, maximum 30), max_rows (default 10,000, maximum 100,000) and max_bytes (default 1 MiB, maximum 10 MiB). SQL can't use ATTACH, DETACH or PRAGMA. SQL text is limited to 64 KiB, with up to 256 parameters.

Moon: HTTP

Network access is off by default. You allow specific hosts when you start the program, and a program can't change this itself. Redirects aren't followed, and proxy settings are ignored.

FUBBY_NET_ALLOWLIST=example.com,api.example.com fubby run app.fab
func main() {
    if !moon.security.network_allowed("example.com") {
        print("Start with FUBBY_NET_ALLOWLIST=example.com")
        return
    }
    let response = moon.http.get("https://example.com/", {"timeout_ms": 3000})
    print(response["status"], str_length(response["body"]))

    let sent = moon.http.post(
        "https://example.com/items",
        json_encode({"name": "pen"}),
        {"headers": {"content-type": "application/json"}},
    )
    print(sent["status"])
}

Responses are maps with status, headers, body and url. Options are headers, timeout_ms (default 5 seconds, maximum 30) and max_bytes (default 1 MiB, maximum 10 MiB). Only GET and POST are available, and bodies must be UTF-8 text. moon.security.limits() returns every limit as a map.

Bela: background tasks

Bela runs named functions at the same time on a small pool of workers. Each task runs separately and shares no objects with its parent. Data passes between them as JSON, so a task gets a copy.

func square() {
    let input = bela.input()
    bela.result(input["value"] * input["value"])
}

func producer() {
    let channel = bela.input()["channel"]
    bela.send(channel, "ready")
}

func main() {
    let task = bela.spawn("square", {"value": 12})
    print(bela.join(task))

    let channel = bela.channel(2)
    bela.spawn("producer", {"channel": channel})
    print(bela.recv(channel))
    bela.close(channel)
}
144
ready

The limits page lists Bela's bounds, and how to change them.