Chacun est un programme entier de ce dépôt, construit et exécuté par la suite de tests. Le texte au-dessus de chacun est le commentaire en tête du fichier.
The smallest kealeb program that is worth reading.
tools/build.sh examples/hello.keal && build/hello
Three routes: a page built out of components, a plain-text handler that reads what the path captured, and a form that posts to itself.
val site = app("kealeb — hello")
site.page("/", { req -> column([
h1("kealeb"),
p("A web framework written in Keal. This page is a tree of components, rendered on the server."),
card([
h3("Try"),
ul([
li([link("/hi/world", "/hi/world"), txt(" — a path parameter")]),
li([link("/greet", "/greet"), txt(" — a form, posting to itself")])
])
]),
p("Served from ${req.peer}.").cls("kb-muted")
])})
site.get("/hi/{name}", { req -> text("hello ${req.param("name")}") })
site.page("/greet", { req -> column([
h1("Greet"),
el("form").attr("method", "post").add(row([
el("input").cls("kb-input").attr("name", "name").attr("placeholder", "your name"),
submit("Say hello")
]))
])}, "kealeb — greet")
site.post("/greet", { req ->
val name = req.form().get("name") ?: ""
html(doc("kealeb — greet", column([
h1("Hello, ${name.isEmpty() ? "stranger" : name}"),
link("/greet", "again")
])))
})
site.run(8080)A live page: the state is on the server, and only the difference travels.
tools/build.sh examples/counter.keal && build/counter
livePage calls the outer function once per visitor. Whatever it closes over is that visitor's own state — there is no session map to key correctly, because the closure is the session. What it answers is called again after every event, and what reaches the browser is the list of changes between the tree it built last time and the tree it built now.
val site = app("kealeb — counter")
site.livePage("/", { req ->
var count = 0
var step = 1
view({ -> column([
h1("Clicked ${count} times"),
p("Nothing on this page is JavaScript you wrote. The tree lives on the server.").cls("kb-muted"),
card([
row([
button("−${step}", { e -> count = count - step }),
button("+${step}", { e -> count = count + step }).cls("kb-primary"),
button("reset", { e -> count = 0 })
]),
row([
label("step"),
select("${step}", [("1", "1"), ("5", "5"), ("10", "10")],
{ e -> step = e.number(1) })
])
]),
shownWhen(count > 9, p("Nine is enough, surely.").cls("kb-muted"))
]) })
})
/// The port is an argument so that a test can ask for 0 and be told which one
/// the machine chose. Hard-coding it would make this example unrunnable
/// while anything else holds the port — including a previous run of itself.
val chosen = args().size > 0 ? (args()[0].toInt() ?: 8080) : 8080
site.run(chosen)A list you can add to, tick off and filter — the example that shows what a live page is actually for.
tools/build.sh examples/todo.keal && build/todo
Everything here is Keal. There is no template, no client state and no JavaScript: the page is a function from three variables to a tree, and the framework works out what changed. Typing in the field sends one event per keystroke and gets back one patch — the field's own value, because the server is what decides what a field holds. Nothing else on the page moves until the task is added.
Every row says which task it is with .keyed. Without it a node's identity is its position, so removing the first row would hand its browser node — its focus, its caret — to the task that moved up into its place.
Two shapes here are the language's, not the framework's. They are written out separately because they are separate: when one of them stops being true, the other still is, and nobody should remove both in one edit.
The helpers are top-level, not nested in the factory. keal build does not compile nested functions — it refuses them by name rather than getting them wrong. When it does compile them, setDone and forget belong inside the factory, next to the list they act on.
They take the list and an index, not the task. The contents of a parameter belong to the caller unless the signature says var, and a lambda's parameter cannot say it — so a handler cannot be handed a Task it is allowed to change. This one is not waiting on a compiler: it follows from what a function type can express, and it would still be true with nested functions.
class Task(public val id: Int, public var what: String, public var done: Bool)
/// Where a task's identity comes from. It has to come from somewhere other
/// than the position in the list: remove the first row and every row below
/// moves up one, and without an identity the browser keeps the node — and
/// with it the focus and the caret, which now belong to a different task.
var nextId = 0
func freshTask(what: String, done: Bool): Task {
nextId = nextId + 1
return Task(nextId, what, done)
}
/// Tick one off, or un-tick it.
proc setDone(var tasks: List<Task>, at: Int, on: Bool) {
if ((at < 0) or (at >= tasks.size)) { return }
tasks[at].done = on
}
/// Take one out.
proc forget(var tasks: List<Task>, at: Int) {
if ((at < 0) or (at >= tasks.size)) { return }
tasks.removeAt(at)
}
val site = app("kealeb — tasks")
site.livePage("/", { req ->
val tasks: List<Task> = [freshTask("Read the guide", true), freshTask("Write a page", false)]
var draft = ""
var showing = "all"
val add = { e: Ev ->
val what = draft.trim()
if (not what.isEmpty()) {
tasks.add(freshTask(what, false))
draft = ""
}
}
view({ ->
val rows: List<Node> = []
var i = 0
for (t in tasks) {
val at = i
i = i + 1
val wanted = (showing == "all") or ((showing == "done") == t.done)
if (not wanted) { continue }
val what = span(t.what)
if (t.done) { what.style("text-decoration: line-through; opacity: .55") }
rows.add(row([
checkbox(t.done, { e -> setDone(tasks, at, e.checked()) }),
what,
button("remove", { e -> forget(tasks, at) }).cls("kb-danger")
]).keyed("task-${t.id}"))
}
val left = tasks.count({ t -> not t.done })
column([
h1("Tasks"),
p("${left} left of ${tasks.size}.").cls("kb-muted"),
card([
row([
field(draft, { e -> draft = e.value }).style("flex: 1"),
button("Add", add).cls("kb-primary")
]),
row([
label("show"),
select(showing, [("all", "everything"), ("todo", "not done"), ("done", "done")],
{ e -> showing = e.value })
])
]),
column(rows),
shownWhen(rows.isEmpty(), p("Nothing here.").cls("kb-muted"))
])
})
})
site.run(8080)A live page whose state is a database — the example that ties the two halves together.
tools/build.sh examples/notes.keal -lsqlite3 && build/notes
The -lsqlite3 is the whole cost of the database layer, and it is only paid by a program that imports it. Stop the server and start it again: the notes are still there, which is the point.
The page is a function of the database. Every render runs the query again rather than keeping a copy in the session, so there is nothing to invalidate and nothing that can be stale. That is the right shape for a table of this size and the wrong one for a million rows, where the query would want a limit and a page number; the framework does not hide which one you are writing.
Two open browsers see each other's notes, because they share the database — but only after the second one does something, because a live page rebuilds on its own events. site.live.refreshAll() in a timer is how a page learns about a change it did not cause, and the timer below shows it.
val db = openDb("notes.db")
/// Add one, and answer whether it worked. A top-level `proc` taking `var db`
/// rather than a lambda closing over it: the closures in the page below may
/// read `db`, but the ones that change it are named, so the writes are a list
/// you can find.
proc add(var db: Db, body: String) {
val text = body.trim()
if (text.isEmpty()) { return }
db.run("insert into note(body, made) values (?, ?)", [vText(text), vInt(epochS())])
}
proc setDone(var db: Db, id: Int, done: Bool) {
db.run("update note set done = ? where id = ?", [vBool(done), vInt(id)])
}
proc forget(var db: Db, id: Int) {
db.run("delete from note where id = ?", [vInt(id)])
}
val site = app("kealeb — notes")
/// Keal calls `main` by itself once the top level has run, so this is not
/// called from anywhere: naming it `main` **and** calling it would run the
/// whole program twice.
proc main() {
if (db == null) {
println("could not open notes.db")
exit(1)
}
// Every migration this program has ever had, in order, never reordered.
// A database already at version 2 runs nothing.
val reached = db.migrate([
"create table note(id integer primary key, body text not null, done integer not null default 0)",
"alter table note add column made integer not null default 0"
])
if (reached < 0) {
println("the schema could not be brought up to date: ${db.error()}")
exit(1)
}
println("notes.db is at version ${reached}, holding ${db.value("select count(*) from note").int()} note(s)")
site.livePage("/", { req ->
var draft = ""
view({ ->
val rows = db.query("select id, body, done from note order by done, id desc limit 200", [])
val left = db.value("select count(*) from note where done = 0").int()
val items: List<Node> = []
for (r in rows) {
val id = r.int("id")
val done = r.bool("done")
val body = span(r.text("body"))
if (done) { body.style("text-decoration: line-through; opacity: .55") }
items.add(row([
checkbox(done, { e -> setDone(db, id, e.checked()) }),
body,
button("remove", { e -> forget(db, id) }).cls("kb-danger")
]).keyed("note-${id}"))
}
column([
h1("Notes"),
p("${left} left, of ${rows.size} shown. Kept in notes.db — stop the server and come back.").cls("kb-muted"),
card([row([
field(draft, { e -> draft = e.value }).style("flex: 1"),
button("Add", { e -> add(db, draft); draft = "" }).cls("kb-primary")
])]),
column(items),
shownWhen(items.isEmpty(), p("Nothing yet.").cls("kb-muted"))
])
})
})
// A page learns about a change it did not cause when something tells it
// to look again. Once a second is coarse and it is honest about being so:
// a rebuild that finds nothing different sends nothing, so an idle page
// costs a query and no bytes.
site.every(1000, { -> site.live.refreshAll() })
// The port is an argument so a test can ask for 0 and be told which one it
// got. Hard-coding it makes the example unrunnable while anything else
// holds the port — including a previous run of itself.
site.run(args().size > 0 ? (args()[0].toInt() ?: 8080) : 8080)
}Signing in: a password in a database, a session in a cookie, a page nobody else can see.
tools/build.sh examples/signin.keal -lsqlite3 && build/signin
Everything security-shaped here is four lines — site.secure(a), one requireUser, one hashPassword, one checkPassword — and the rest is an ordinary application. The CSRF token in the two forms below was not written by anybody: secure walks each page and puts it there.
The secret is read from signin.secret, made on the first run. Keep that file out of git and back it up: it signs every session and peppers every password, so losing it signs everybody out and makes every stored hash unverifiable.
val db = openDb("signin.db")
val site = app("kealeb — sign in")
proc userPage(var site: App, a: Auth, var db: Db) {
site.get("/private", requireUser(a, { req ->
val who = a.userOf(req) ?: "?"
html(doc("Private", column([
h1("Hello, ${who}"),
p("Only somebody signed in reaches this page. A stranger is sent to the sign-in form with `?next=/private`, and lands back here afterwards.").cls("kb-muted"),
el("form").attr("method", "post").attr("action", "/sign-out").add(submit("Sign out")),
link("/", "home")
])))
}))
}
/// Keal calls `main` by itself once the top level has run, so this is not
/// called from anywhere: naming it `main` **and** calling it would run the
/// whole program twice.
proc main() {
if (db == null) {
println("could not open signin.db")
exit(1)
}
if (db.migrate([
"create table person(name text primary key, secret text not null, made integer not null)"
]) < 0) {
println("the schema could not be brought up to date: ${db.error()}")
exit(1)
}
val a = auth(secretFromFile("signin.secret"))
// Only while developing: the session cookie is sent without `Secure` so it
// survives plain http on localhost. In production this line goes.
a.secure = false
site.secure(a)
site.page("/", { req ->
val who = a.userOf(req)
column([
h1("kealeb — signing in"),
p("${db.value("select count(*) from person").int()} account(s) in signin.db.").cls("kb-muted"),
card([
who == null ? p("Nobody is signed in.") : p("Signed in as ${who}."),
row([link("/private", "a page for members"), link("/sign-in", "sign in"),
link("/register", "make an account")])
])
])
})
// ------------------------------------------------------------ sign in
site.page("/sign-in", { req -> column([
h1("Sign in"),
shownWhen(req.queryOr("e") == "no", p("That name and password do not go together.").cls("kb-danger")),
shownWhen(req.queryOr("e") == "slow", p("Too many tries. Wait a minute.").cls("kb-danger")),
card([el("form").attr("method", "post").addAll([
column([
label("name"), el("input").cls("kb-input").attr("name", "name").attr("autocomplete", "username"),
label("password"), el("input").cls("kb-input").attr("type", "password").attr("name", "password").attr("autocomplete", "current-password"),
el("input").attr("type", "hidden").attr("name", "next").attr("value", nextAfter(req)),
row([submit("Sign in")])
])
])]),
link("/register", "or make an account")
])}, "Sign in")
site.post("/sign-in", { req ->
val form = req.form()
val name = (form.get("name") ?: "").trim()
val password = form.get("password") ?: ""
val where = form.get("next") ?: "/"
// Both the name and the peer, so one account cannot be locked out from
// elsewhere and one machine cannot work through a list of names.
if (not (a.mayTry("name:" + name) and a.mayTry("from:" + req.peer))) {
redirect("/sign-in?e=slow", 303)
} else {
val row = db.one("select secret from person where name = ?", [vText(name)])
// The same work whether or not the account exists: verifying
// against a hash of nothing takes as long as verifying against a
// real one, so how long the answer took does not say whether the
// name is a name.
var stored = ""
if (row == null) { stored = a.hashPassword("no such person") } else { stored = row.text("secret") }
if ((row != null) and a.checkPassword(password, stored)) {
a.succeeded("name:" + name)
if (a.needsRehash(stored)) {
db.run("update person set secret = ? where name = ?",
[vText(a.hashPassword(password)), vText(name)])
}
a.signIn(redirect(where.startsWith("/") ? where : "/", 303), name)
} else {
redirect("/sign-in?e=no", 303)
}
}
})
site.post("/sign-out", { req -> a.signOut(redirect("/", 303)) })
// ------------------------------------------------------------ register
site.page("/register", { req -> column([
h1("Make an account"),
shownWhen(req.queryOr("e") == "taken", p("That name is taken.").cls("kb-danger")),
shownWhen(req.queryOr("e") == "short", p("Eight characters at least, please.").cls("kb-danger")),
card([el("form").attr("method", "post").add(column([
label("name"), el("input").cls("kb-input").attr("name", "name"),
label("password"), el("input").cls("kb-input").attr("type", "password").attr("name", "password").attr("autocomplete", "new-password"),
row([submit("Make it")])
]))]),
link("/sign-in", "or sign in")
])}, "Register")
site.post("/register", { req ->
val form = req.form()
val name = (form.get("name") ?: "").trim()
val password = form.get("password") ?: ""
if (name.isEmpty() or (password.length < 8)) {
redirect("/register?e=short", 303)
} else if (db.one("select name from person where name = ?", [vText(name)]) != null) {
redirect("/register?e=taken", 303)
} else {
db.run("insert into person(name, secret, made) values (?, ?, ?)",
[vText(name), vText(a.hashPassword(password)), vInt(epochS())])
a.signIn(redirect("/private", 303), name)
}
})
userPage(site, a, db)
// The port is an argument so a test can ask for 0 and be told which one it
// got. Hard-coding it makes the example unrunnable while anything else
// holds the port — including a previous run of itself.
site.run(args().size > 0 ? (args()[0].toInt() ?: 8080) : 8080)
}Serving a directory — and what happens when what is in it is bigger than the machine.
tools/build.sh examples/files.keal && build/files 8080 ./public
Two lines do the work. Everything else in this file is the index page, so there is something to click.
A file under a megabyte is read into memory: it can be compressed, which is worth four or five times its size on a stylesheet, and holding it costs nothing. Above that the server opens it and sends it as the socket takes it, a quarter of a megabyte at a time. Measured on a hundred-megabyte file: 3 MB of memory streaming it, 235 MB reading it whole — which is the difference between serving it and refusing to.
Both paths answer Range, so a video seeks and a download resumes.
val site = app("kealeb — files")
/// Keal calls `main` by itself once the top level has run, so this is not
/// called from anywhere.
proc main() {
val port = args().size > 0 ? (args()[0].toInt() ?: 8080) : 8080
val dir = args().size > 1 ? args()[1] : "./public"
site.files("/f", dir)
site.page("/", { req -> column([
h1("Files"),
p("Serving ${dir} at /f — with ETags, byte ranges, and gzip on what is worth it.").cls("kb-muted"),
card([
h3("What is in there"),
listing(dir)
]),
p("A file over ${streamFrom / (1024 * 1024)} MB is streamed rather than read: the server holds a quarter of a megabyte of it, whatever its size.").cls("kb-muted")
])})
site.run(port, "127.0.0.1")
}
func listing(dir: String): Node {
val names = listDir(dir)
if (names == null) { return p("There is no ${dir} to serve.").cls("kb-danger") }
if (names.isEmpty()) { return p("It is empty.").cls("kb-muted") }
val rows: List<Node> = []
for (name in names.sorted()) {
val size = fileSize("${dir}/${name}")
if (size < 0) { continue }
rows.add(row([
link("/f/${name}", name),
span(readable(size)).cls("kb-muted"),
span(size > streamFrom ? "streamed" : "in memory").cls("kb-muted")
]).keyed(name))
}
return column(rows)
}
func readable(bytes: Int): String {
if (bytes < 1024) { return "${bytes} B" }
if (bytes < (1024 * 1024)) { return "${bytes / 1024} KB" }
return "${bytes / (1024 * 1024)} MB"
}