mélangej · ABCL on Jooby and Netty
Quickstart
What you need
A JDK 17 or newer. 17 is what the build compiles for and what production runs; Jooby 3's classes are compiled for 17, so an older Java does not get a slower server, it gets UnsupportedClassVersionError from inside a fasl load. The examples are checked on 17, 21 and 26, and examples/hello/run.sh refuses 11 with a sentence instead of that trace. Any build of OpenJDK will do:
brew install openjdk@17 # macOS
sudo apt install openjdk-17-jdk-headless # Debian, Ubuntu
nix-shell -p openjdk17_headless # Nix
export JAVA_HOME=$(/usr/libexec/java_home -v 17) # macOS, if java is not 17
A JRE 17 is enough to run a release tarball; building from source needs the JDK. The Gradle wrapper (8.10.2) starts on the JDK you have - checked on 11, 17 and 21 - and if that is not a 17 it downloads one to compile with, into ~/.gradle/jdks, once.
Everything else is either fetched for you or optional:
- Gradle, ABCL, Jooby, Netty, the JDBC drivers - for building;
core/gradlewfetches them. fossil- to clone the source, and to host fossil repositories (fossil-scm.org).curlandsha256sum(shasum -a 256on macOS) - for the release tarball; usually there already.hg,jj,lore- only for the engines you host.- PostgreSQL 16 - optional, for
meta.dbon Postgres; SQLite is built in.
git needs nothing: it is served in-process, by JGit. An engine whose program is missing is offered greyed out on the new-repository form and refused by the API, rather than half-made.
Get it and run it
Two ways, because one way to fetch software is not enough.
With fossil, to get the source and its history:
fossil clone https://HOST/thecommons/spicemine spicemine.fossil # HOST: the instance you are reading this on
mkdir spicemine && cd spicemine && fossil open ../spicemine.fossil
core/gradlew -p core installDist # builds and compiles the Lisp
core/build/install/spicemine/bin/spicemine-server
That is the whole install. The first build downloads Gradle and the jars; after that it is about twenty seconds. The server comes up on :8643 and prints a first-boot admin token once.
With curl, to get a built release and nothing else — no fossil binary, no JDK, no Gradle, no build:
host=https://spicemine.example.org
rel=$(curl -fsS "$host/dist/LATEST") # e.g. spicemine-0.6.0.tar.gz
curl -fsSO "$host/dist/$rel"
curl -fsSO "$host/dist/$rel.sha256"
sha256sum -c "$rel.sha256" # verify before unpacking
tar xzf "$rel"
spicemine-*/bin/spicemine-server
Verify the checksum, every time. It is two lines and it is the only part of this that distinguishes a release from a file that arrived over the network. A JRE 17 or newer is the one thing the tarball still needs.
Any instance can serve its own releases; deploy.sh stages them. An instance that has never staged one has no /dist route at all, so a 404 there means that instance does not publish builds rather than that the release is missing.
Why two: the fossil server runs a browser check against crawlers, which a human with a browser passes without noticing and curl cannot pass at all. So the clone needs the fossil client, and the tarball needs nothing. Both get you the same code — the tarball is the artifact built from the source the clone gives you.
Three environment variables matter:
export JAVA_OPTS="-Xmx256m -Dspice.port=8643 -Dspice.home=/var/lib/spicemine"
spice.home is where repositories and meta.db live; everything else has a default. Chapter 10 covers running it as a service.
Just the pieces
If you want mélangej without spicemine, you need four things on a classpath: abcl, abcl-contrib, jooby, jooby-netty and Netty's transitives. In Gradle:
dependencies {
implementation 'org.abcl:abcl:1.9.2'
implementation 'org.abcl:abcl-contrib:1.9.2'
implementation 'io.jooby:jooby:3.9.1'
implementation 'io.jooby:jooby-netty:3.9.1'
}
Hello
Two versions of the same server. The first is how you would write it: a handler is a function of the request, and an application is a list of routes. The second is the same server with nothing on top - Jooby and the java: package - so that the bottom is something you have seen rather than something you take on trust.
The short way
(defpackage :short
(:use :cl :spice/http)
(:import-from :spice/html :html))
(in-package :short)
;;; A handler is a function of the request context that returns the body.
(defun hello (ctx)
(html (list :h1 "hello, " (ctx-query ctx "who" "world"))))
(defun hello-name (ctx)
(html (list :p "hello, " (ctx-param ctx "name"))))
;;; The application is a list. Symbols, so a redefined HELLO is live
;;; without a restart.
(serve '((:get "/" hello)
(:get "/hello/{name}" hello-name))
:port (property "hello.port" 8080)
:wait t)
core/gradlew -p core installDist # once: the distribution
examples/hello/run.sh short.lisp # http://localhost:8650/
run.sh loads the framework first - examples/melangej.lisp, three loads out of the distribution - so the file above is the whole application. Open /?who=<script> and the tag arrives as text: html escapes every string it is given.
What is going on, one line at a time:
- A route is
(method path handler), and the application is a list of them. It is data, so it can be built withlist, joined withappend, filtered withremove-if, or printed to see what is mounted. - A symbol for the handler is looked up on every request. Redefine
helloat a REPL and the next request runs the new one, with no restart.#'helloor alambdais fixed when the route is mounted. - Answers are HTML (
text/html; charset=utf-8) unless a route says otherwise:(:get "/api" api :type "application/json"). Jooby's own default istext/plain, which a browser shows as source. serve's keywords are the server's options::port,:io-threads,:worker-threads,:type(the default for every route) and:wait, which keeps a script started with--loadfrom ending - and taking the server with it. Without:wait,servereturns the running server.(property "hello.port" 8080)reads-Dhello.port=..., as an integer because the default is one.run.shsets it fromPORT.(ctx-query ctx "who" "world")is the query parameter or the default.
When the handler is short enough to sit in the table, routes writes the lambdas:
(serve (routes
(:get "/" (ctx) (html (list :h1 "hello")))
(:get "/hello/{name}" (ctx) (ctx-param ctx "name")))
:port 8080 :wait t)
Both are ordinary lists, so (append '((:get "/" hello)) (routes ...)) mixes them.
The whole of it:
(serve routes &key port io-threads worker-threads type wait)
mount, start, and return the server (or wait)
(mount app routes &key type)
mount on an app you made with make-app; returns it
(routes (method path (ctx) body...) ...)
a route list from inline handlers
(property name &optional default)
a -D system property
route, def-route, start-server
the same, one route at a time
The same thing by hand
Nothing to require. The whole Java surface here is the java: package that is part of ABCL - no abcl-contrib, no jss, no reader macro. That is also why a server built this way starts in about two seconds: the nine it used to take were those two requires (chapter 11).
(defvar *routes* 0)
(defun make-handler (fn)
;; Jooby reads the bytecode of apply(Context) to learn the return type,
;; and a Lisp closure has no bytecode to read. So: generate a class with
;; a String-typed apply next to the one that actually runs.
(let ((class
(java:jnew-runtime-class
(format nil "Route~d" (incf *routes*))
;; Named, because the default loader's parent is whatever is
;; current, and before ABCL has compiled anything that is the
;; JVM's own, which this constructor does not take.
:class-loader (java::make-memory-class-loader (java:get-default-classloader))
:interfaces '("io.jooby.Route$Handler")
:methods `(("apply" "java.lang.String" ("io.jooby.Context")
(lambda (this ctx) (declare (ignore this ctx)) nil))
("apply" "java.lang.Object" ("io.jooby.Context")
(lambda (this ctx) (declare (ignore this))
(funcall ,fn ctx)))))))
;; The class OBJECT, not its name: a runtime class lives in ABCL's own
;; class loader and `(java:jnew "Route1")` cannot find it.
(java:jnew class)))
(let ((app (java:jnew "io.jooby.Jooby"))
(options (java:jnew "io.jooby.ServerOptions")))
(java:jcall "route" app "GET" "/"
(make-handler (lambda (ctx)
(java:jcall "setResponseType" ctx "text/html")
"<h1>hello from Lisp</h1>")))
(java:jcall "setPort" options
(parse-integer (java:jstatic "getProperty" "java.lang.System"
"hello.port" "8080")))
(java:jcall "setServerOptions" app options)
(java:jcall "start" app)
;; --load returns once this form has run; this keeps the process up.
(loop (sleep 3600)))
java -cp "core/build/install/spicemine/lib/*" -Dhello.port=8650 \
org.armedbear.lisp.Main --noinform --load examples/hello/by-hand.lisp
The :class-loader line is the one you would not guess. ABCL's default is a loader whose parent is whatever is current, and until the image has compiled something that is the JVM's own, which the constructor does not accept: without the line, jnew-runtime-class fails with NoSuchMethodException: MemoryClassLoader(AppClassLoader). A server that compiles anything before its first route never sees it; a five-line script does. This page showed the block without it until 0.34.0, and it did not run.
That is the framework. Everything else is a decision about what goes inside the handler.
A bigger example
For something to start from, the same directory has hello.lisp:
examples/hello/run.sh # http://localhost:8650/
About a hundred lines, three routes, and it loads three files out of the distribution rather than all thirty-three — an application that does not serve git has no use for the git adapter.
It shows the two ways to produce HTML side by side: / builds the page from s-expressions, /tmpl renders the same page from hello.html, an ordinary HTML file with Lisp in <? ?> tags, and /api returns JSON. Chapter 5 is about the difference.
The escaping is demonstrated rather than claimed. Open /?who=<script>alert(1)</script> and read the source: the tag arrives as text, in both renderers, because neither of them has a way to emit it as markup.
The one awkward part, kept in one place
make-handler above is the hand-written twin of %make-handler, the only function in the entire project that touches jnew-runtime-class. spice-http.lisp exports serve, route, def-route and about twenty context accessors, and no other file calls Java at all. If ABCL's interop changes, one file changes.
(def-route (app "GET" "/hello/{name}") (ctx)
(format nil "hello, ~a" (ctx-param ctx "name")))
Adding the rest
boot.lisp loads the layers in order, and each is optional:
(load-part "spice-db") ; (db-open path) (db-query "select ...")
(load-part "spice-auth") ; (hash-password p) (check-password p hash)
(load-part "spice-html") ; (html (:p "escaped by default"))
(load-part "spice-i18n") ; (tr :key)
(load-part "spice-skin") ; (skin-style)
load-part prefers a compiled NAME.abcl and falls back to NAME.lisp. That difference is worth a factor of three — chapter 11.
A page
(def-route (app "GET" "/repos") (ctx)
(ctx-set-type ctx "text/html; charset=utf-8")
(html (list :h1 "repositories")
(list :table
(loop for (name engine) in (db-query "select slug, engine from repos")
collect (list :tr
(list :td name)
(list :td (list :span :class "chip" engine)))))))
No template file, no context dictionary, nothing to remember about escaping. The loop is a child because a list of nodes splices; name is escaped because strings are escaped; and the whole thing is a function you can call from a REPL and read.
Two ways to run it while working
core/gradlew -p core runCore -Pport=8643 # from the tree, loads .lisp sources
core/gradlew -p core installDist # packaged, compiled to fasls
The first is what you keep open; the second is what you deploy.