Rendering on a server¶
lumen-ssr renders a Lumen app to an HTML document once per request. The same
app, the same markup and the same scripts a build writes a static site from,
except that the state is read for the visitor asking: the address they asked
for, the language their browser wants, the record their path names, the data an
API answered a moment ago.
Reach for it when a page depends on who is asking or on data that changes faster than you rebuild. A page whose content is the same for everybody wants a build instead, which costs nothing to serve.
Two things ship. lumen-server is the server you deploy: point it at the
directory a build wrote and it serves the site, rendering every page. The
lumen-ssr crate is the renderer inside it, with no transport of its own: a
request goes in as a struct and a response comes back as one, so it also goes
inside a server you already have.
What you serve¶
A render produces documents and nothing else. Everything else a page loads
still comes from lumenc web:
That writes the stylesheet, the compiled app, the compiled candela program,
the browser runtime pair, lumen.web.json, the catalogues, assets/ and
lumen.site.json, and no documents: serve the files as static files and
answer everything else with a render. The runtime adopts a rendered document
the same way it adopts a built one.
lumen.site.json is the server's file. It names every other file, and it
carries what the build knew that the compiled app does not: the address and
canonical URL, each page's title and description, the locales and their
fallback chain, the size of every image, the [web.seed] values, and the
app's [web.ssr] policy. Read it and you hold the site lumenc web --serve
renders. The browser never loads it.
Every other file is named after its own contents, so the compiled app is
app.<hash>.lmna rather than app.lmna. Read the names out of
lumen.site.json rather than writing them into your server: they change
whenever the app does.
Add --no-runtime to render pages that carry none:
Each page is still produced for the request that asks, and now it is only a
document: no wasm, no boot script, and nothing that takes it over once it is
open. Links load the next page, which is another render. The compiled app and
lumen.site.json are still written, because that is what you render from; the
runtime files, the manifest and the catalogues are not, because nothing in a
browser loads them. The compiled app carries the catalogues itself, so
lumen.site.json names none and SsrSite::from_build renders every language
from the app alone when it is handed no catalogue.
Build with --render csr instead when you want documents to fall back to.
Which of the two answers a request is then yours to decide, and so is
rebuilding them when the app changes.
Trying it without writing a server¶
That emits the site, then runs lumen-server --dev on it, with every page
coming from a render of the app for the request that asked. Development mode
listens on 127.0.0.1, and --host <addr> is what widens that and says so; a
render that fails says why in the page; there are no worker processes, and
there is no render time limit. Anything the public reaches belongs on
lumen-server without --dev.
Files keep their own path through it: a stylesheet, an artifact and the wasm module are read from the directory the build wrote while a page is being rendered, so nothing a page needs waits behind the page. Renders queue, because a process renders one at a time.
A render reaches the hosts [web.ssr] allow_hosts lists and no others, the
same policy an embedder applies with with_policy. --allow-host adds one
for this run:
Warnings a render comes back with are printed once each, so a page reloaded twenty times does not bury a new one. Whether anything is reading them is not the server's business: a log line that cannot be written is dropped, rather than ending a process in the middle of answering somebody.
Running in production¶
lumen-server serves a site built with --render ssr. It installs beside
lumenc, and it is published as a container image. It serves a site built
with --render static or csr too, as the files it holds.
It reads lumen.site.json and nothing else about the app: not lumen.toml,
not the app's source. What the app allows a render to do, its fetch hosts,
its request cap and the headers it reads, is the [web.ssr] policy the build
wrote into that file. How the server runs is yours to say, with flags or with
the LUMEN_* variable beside each one; a flag wins over its variable. The
reference lists them all.
lumen-server dist/web --bind 0.0.0.0 --port 8080 --workers 4 --log-format json
LUMEN_BIND=0.0.0.0 LUMEN_WORKERS=4 lumen-server dist/web
It listens on 127.0.0.1 unless --bind says otherwise. TLS and compression
are left to the reverse proxy or load balancer in front of it.
Workers¶
A process renders one page at a time, so --workers N runs N worker
processes that share the port. A supervisor binds the port, starts the
workers, and keeps the socket open while it replaces one that exits, so a
connection that arrives meanwhile waits rather than being refused. Files are
served while a page renders, on every worker.
Each worker holds a bounded number of connections and a short queue of
requests waiting for a render. A worker whose queue is full takes no new
connection while another worker shares the port, so an idle worker answers it
instead. A page asked for when the queue is full, on a connection the worker
already holds or when it is the only worker, is answered with a 503 and
Retry-After, so a balancer sends it elsewhere rather than letting it wait
behind everyone else.
Windows has no supervisor: lumen-server runs as one process there, and
--workers above 1 is refused. Run one per port behind your balancer instead.
Health¶
Two endpoints answer on every worker:
| Path | Answers |
|---|---|
/_lumen/healthz |
200 while the process is running. For liveness. |
/_lumen/readyz |
200 when a page asked for now would be rendered, and 503 while the server is stopping or its render queue is full. For readiness. |
--health-path /ops moves them to /ops/healthz and /ops/readyz, for an
app with a page of its own at /_lumen. lumen-server probe asks the liveness
endpoint of the server its own flags and variables describe, and exits 0 when
it answers; that is what the container image checks itself with.
Logging¶
Every request is one access line on standard output: the time, the visitor's
address, the method and path, the status, the body size, and how long it
took. The server's own messages, and each warning a render comes back with,
go to standard error; a warning is written once however many renders repeat
it. --log-format json writes both as one JSON object per line for a log
collector.
No header value and no query string is ever written, so a Cookie, an
Authorization header, or a token in a sign-in or password-reset link never
reaches a log.
A render that fails is logged with what went wrong, and the visitor gets the
status and nothing else. Only --dev, which lumenc web --serve runs, puts
the reason in the page.
Stopping¶
On SIGTERM or Ctrl-C the server reports itself not ready, stops accepting,
closes idle connections, and lets the requests it is answering finish. It
exits once they have, or when --shutdown-grace runs out.
Recycling and runaway renders¶
--max-renders N retires a worker after about N renders and the supervisor
starts a fresh one; each worker picks a slightly different number, so they do
not all restart at once. A process's memory grows slowly over many renders,
and a fresh process is how to give it back.
A render that ticks past --render-timeout cannot be stopped from inside the
process that is running it. Its visitor gets a 504, and the worker finishes
what else it is answering and exits so the supervisor starts a fresh one.
Requests queued behind that render are answered with a 503. A server running
as one process, on Windows or under --dev, exits the same way with nothing
to start it again, so run it under a service manager that restarts it.
Behind a proxy¶
Whether a request arrived over TLS, and from where, is what the proxy in
front says in X-Forwarded-Proto and X-Forwarded-For. Those headers are
believed only from the addresses --trusted-proxy names, and dropped from
every other request before the app sees them:
With a trusted proxy, request.secure reads what the proxy said about TLS and
the access log records the visitor's address rather than the proxy's.
A request that a proxy and the server could read two ways, such as one with a
folded header line, whitespace before a header's colon, or two
Content-Length headers, is answered with a 400 and its connection closed, so
the two never disagree about where one request ends and the next begins.
Caching¶
A file whose name carries the hash of its contents, which is every file a
build writes except the documents, lumen.web.json and lumen.site.json, is
served with Cache-Control: public, max-age=31536000, immutable: a new build
is a new name, so a browser or CDN never has to ask again. Every other file is served
with no-cache. A rendered page is no-store unless the app sets its own
Cache-Control with response_header. lumen.site.json is never served.
The container image¶
ghcr.io/lumen-fx/lumen-server carries the same binary for Linux on x86_64
and aarch64, tagged with each release version, latest for the newest
release, and nightly for the newest nightly build. It listens on port 8080
on every interface, runs as a user without root, checks its own health with
lumen-server probe, and serves the site mounted at /site:
lumenc web myapp --render ssr --out dist/web
docker run --rm -p 8080:8080 -v "$PWD/dist/web:/site:ro" ghcr.io/lumen-fx/lumen-server
Flags go after the image name, and the variables work with -e:
docker run --rm -p 8080:8080 -v "$PWD/dist/web:/site:ro" \
-e LUMEN_WORKERS=4 -e LUMEN_LOG_FORMAT=json \
ghcr.io/lumen-fx/lumen-server --trusted-proxy 10.0.0.0/8
Move the port with LUMEN_PORT rather than --port, so the health check asks
the port the server listens on. To ship the site inside an image of your own:
Rendering¶
use std::path::Path;
use std::sync::Arc;
use lumen_ssr::{RenderOptions, Renderer, SERVER_SPEC_FILE, ServerSpec, SsrRequest, SsrSite};
let dir = Path::new("dist/web");
let spec = ServerSpec::from_json(&std::fs::read(dir.join(SERVER_SPEC_FILE))?)?;
let artifact = std::fs::read(dir.join(&spec.web.artifact))?;
let mut catalogues = Vec::new();
for (tag, path) in &spec.web.catalogues {
catalogues.push((tag.clone(), std::fs::read_to_string(dir.join(path))?));
}
let site = SsrSite::from_build(&artifact, &spec, catalogues)?;
let options = RenderOptions::default().with_policy(site.policy());
let renderer = Renderer::start(Arc::new(site), options)?;
let response = renderer.render(
SsrRequest::get("/user/42?tab=posts").with_header("Accept-Language", "en-GB"),
)?;
// response.status, response.headers and response.body are what to send.
SsrSite::from_build takes the files already read, so where they live and
how they are read stays your server's business. The site it builds is the one
lumenc web --serve renders: a tree per locale, translated from the
catalogues, with the titles, image sizes and declared state the build wrote
down. with_policy applies the app's [web.ssr] policy; add to the options
after it for anything your deployment allows on top.
A file written by another version of lumenc is refused with a message
naming both versions, rather than read into a site that points at the wrong
files. Rebuild the site with the lumenc that matches your server.
This is what lumen-server does for every page. Embed it yourself when the
pages belong inside a server you already run.
render blocks until the document is written, and it is safe to call from any
thread: calls queue. A server facing the public bounds the queue with
RenderOptions::queue and calls try_render with a time limit instead: a
full queue answers SsrError::Busy at once, and a render still running past
the limit answers SsrError::TimedOut. A render past its limit cannot be
stopped, so the renderer answers nothing after it and is_stopped says so;
the process that owns it is the thing to restart.
A script failure stays inside the render it happened in. A handler that raises, and the script engine itself giving up mid-call, both end that one call, are reported, and leave the renderer serving; the document goes out with whatever the script had written by then. A panic from the app's own Rust is the other case: it takes the renderer with it, and the requests after it are answered with what happened and a 500. Start the server again once you have fixed what panicked.
More than one language¶
A site holds one tree per language it answers in, and a request picks one. The
trees are the app already translated: every string an element marked
translatable shows is resolved into the markup before a document is written
from it, and text carrying a format is
written for the tree's locale as the document is, so a page arrives in its
language with nothing running.
lumenc web builds them from the locale/*.ftl catalogues beside your markup,
one per [web] locales entry, and SsrSite::from_build builds the same trees
from lumen.site.json; both call lumen_web::locale_trees. A server that
builds its site by hand, from SsrSite::new, can call it too, or build a tree
itself and hand it over:
use lumen_web::{LocaleSpec, PageSpec, SiteSpec};
// `catalogue` is a `SharedI18n` holding the German messages, loaded from
// wherever your deployment keeps them.
let german = SiteSpec {
pages: vec![PageSpec::new(
"index",
lumen_web::translate_ir(&compiled.ir, &catalogue),
)],
locale: LocaleSpec {
default_locale: "en-US".to_string(),
..LocaleSpec::new("de-DE")
},
..site.spec().clone()
};
let site = site.with_locale(german)?;
Every tree has to answer for every page the site has, because a request resolves to a page before it resolves to a language; a tree missing one is refused rather than answered from another language.
A tree whose locale is its own default_locale is the root tree: it replaces
the tree at the site root, and any other tree for that locale goes. A tree in
the root's locale that is not marked as the root is refused, because two trees
would answer for one language. Every tree's alternates and default locale are
set from the trees the site holds, so the hreflang links always name the
languages the site answers in.
The app a render runs reads the catalogues too: a script's t() answers in the
language of the tree the request resolved to, and locale() names it. Hand the
renderer the catalogue sources, and the fallback chain [app] fallback_locale
names, with with_catalogues:
let site = site.with_catalogues(
vec![
("en-US".to_string(), english_ftl),
("de-DE".to_string(), german_ftl),
],
Vec::new(), // the default chain, which ends in en-US
)?;
Each catalogue is parsed once, here, so one that will not load is refused
rather than failing the first request. Every render builds its own registry
from them, so a set_locale in one request never reaches the next. A site
with no catalogues renders with no translator, and t()
answers with its key.
A script's format_* calls answer for the same locale, with or without
catalogues: format_currency(1234.5, "EUR") writes 1.234,50 with a trailing
euro sign in the German tree and 1,234.50 with a leading one in the English
tree, the way markup format is written.
Which tree answers is decided in this order:
SsrRequest::with_locale("de-DE"), for a proxy or a language cookie that has already decided. A tag the site holds no tree for falls through to the rest, and the response says so in its warnings.- A locale prefix on the path:
/de-DE/settings.htmlis thesettingspage of the German tree, which is what anhreflanglink points at. The tree at the site root has no prefix. Accept-Language, matched against the tags the site holds. A range reaches a tag that continues it, sode-ATreaches a site that holdsde-DE, andq=0is a refusal rather than a low preference. This reads the header as it arrived: which document the server sends is the server's decision, so aHeaderPolicythat keepsaccept-languagefrom the app still negotiates.- The site's default locale, which is the tree at the root.
Every response names the language it is in with Content-Language, and a site
holding more than one tree also sends Vary: Accept-Language so a shared cache
does not hand one visitor's language to the next. Both are set before the app's
own headers, so a page that sets either itself is the one that is sent.
Under render = "ssr" a build writes no documents for any tree: the renderer
answers /de-DE/settings.html itself, and a file beside it would be a second
answer for one address.
One render at a time, per process¶
Renderer::start fails with AlreadyRunning if the process already has a
renderer. An app reads what its scripts write through buses that belong to the
process rather than to the app, so two apps ticking at once take each other's
writes and one visitor's data lands in another visitor's page. The check turns
that into an error at startup.
Serve more requests at once by running more processes behind whatever balances
them; lumen-server --workers does that on one machine. That is the scaling
story, and it is the one to plan for.
A request is a whole life¶
Every request builds an app, ticks it, reads it and drops it. Nothing survives
to the next one: on_start and on_ready run every time, and a signal written
for one visitor is gone before the next arrives. Anything that has to outlive a
request lives in your database.
The boot is what that costs. It is small enough to pay per request and does
not grow with the number of requests a process has served; cargo bench -p
lumen-portable --bench boot measures it on your own app.
What the app reads of the request¶
The address arrives as reserved signals, which markup binds to by name and
every script host reads with signal_get:
| Signal | Holds |
|---|---|
request.method |
The method, uppercased. |
request.path |
The path, without the query string. |
request.query |
The query string, without the leading ?. |
request.hash |
The fragment, without the leading #, normally empty. |
request.secure |
Whether the request arrived over TLS. |
They are written before the app's first script runs, so on_start can decide
what to publish from the address it is being asked for.
Routing is the desktop's: a path resolves to the page with the longest matching
key, and the rest of it lands in route.segment. A request for /user/42 in an
app with user.lmn renders the user page with /42 on route.segment, which
is the page's to parse.
A link inside a built site points at the document that build wrote, so
/settings.html is a request for the settings page and /index.html for the
entry page, whatever it is keyed as. request.path still holds the address as
it arrived.
An address no page answers for¶
/nowhere matches no page key and is no document a build wrote, so nothing is
rendered for it: the response is a 404 carrying the app shell, which is the
same 404.html a static build writes for a path its host has no file for. A
site answers such an address the same way whether it is rendered or built.
The shell holds no state, so it is written once and reused, and the app is not built for it. An address anyone can guess would otherwise cost a whole app boot to arrive at a document that is the same every time.
A deep path is not this case. /user/42 in an app with user.lmn names the
user page, so it renders as that page with /42 on route.segment.
To have the app answer such an address itself, ask which page it names and render one the app does have:
let response = match site.page_for(path) {
Some(_) => renderer.render(SsrRequest::get(path))?,
// `notfound.lmn` renders it, reading the address off `route.segment`.
None => {
let mut own = renderer.render(SsrRequest::get(&format!("/notfound{path}")))?;
own.status = 404;
own
}
};
The headers, the cookies and the body are read one at a time, because a page has no business holding all of them:
import "lumen.cdl";
fn main() {}
fn on_start() {
lumen::signal_set("language", lumen::request_header("accept-language"));
lumen::signal_set("session", lumen::request_cookie("session"));
lumen::signal_set("submitted", lumen::request_body());
lumen::signal_set("tab", window::location_query());
}
Each is empty when there is nothing to read, which is every desktop app and
every page in a browser. In a browser window::location_query() and
window::location_hash() read the address the page was opened at.
Headers are allowed by name¶
An app runs somebody else's code against somebody else's request, so it reads
the headers that say what a browser is and none that say who is using it:
accept, accept-encoding, accept-language, host, referer,
user-agent, the x-forwarded-* family and x-request-id. Anything else,
including Authorization, Cookie and Proxy-Authorization, is named
explicitly or not read at all:
use lumen_ssr::{HeaderPolicy, RenderOptions};
let options = RenderOptions {
headers: HeaderPolicy::default().allow("Authorization"),
..RenderOptions::default()
};
[web.ssr] headers = ["authorization"] in lumen.toml says the same thing
from the app's side, and with_policy applies it.
A cookie is different: request_cookie(name) reads one by name whether or not
the Cookie header itself is allowed, which is the granularity worth having.
An app that wants the whole jar as a string allows cookie like any other
header.
What the app says about the response¶
Three builtins answer the request with something other than a plain document:
| Builtin | Does |
|---|---|
response_status(status) |
Answers with that status, clamped to 100..=599. |
response_header(name, value) |
Sets a response header; setting a name twice replaces it. |
redirect(location) |
Answers with a redirect instead of a document. |
A redirect stops the render: the response carries Location, a 302 unless the
app set a status of its own, and no body.
A header a script sets is checked before it is sent. A value carrying a line
break is refused, because it would end the header and start something else, and
so are Content-Length and Transfer-Encoding, because how the body is framed
is the server's to say. Both refusals arrive in response.warnings rather than
in silence.
Waiting for the app's own requests¶
An app that fetches its data in on_ready has none of it on the tick the
request goes out, so a render waits: what the app asked for is counted, and the
document is written once nothing is outstanding and the app's state has stopped
changing. A page whose list comes from an API is rendered with the list in it.
An app still waiting when its budget runs out is answered with what it has, plus
an X-Lumen-Render: partial header and a warning saying so. A slow upstream
then gives a visitor a working page the browser finishes, rather than an error
page. A reply that arrives after that is dropped: it belonged to a request that
is over, and the next visitor's page is not the place for it.
Timers are not waited for. A page does not wait on a clock.
The network is allowed by host¶
An app asking for an address is a visitor's request making your server make a request, so a render reaches only the hosts you name and makes a bounded number of requests:
use lumen_ssr::{FetchPolicy, RenderOptions};
let options = RenderOptions {
fetch: FetchPolicy {
max_requests: 4,
..FetchPolicy::default().allow_host("api.example.com")
},
..RenderOptions::default()
};
Nothing is allowed by default. A request to anything else is answered with an error the app reads as a failed reply, so the page renders the way it does in a browser with no network. Subdomains are not implied; name each one.
An app names its own hosts and cap in lumen.toml, and with_policy applies
them:
The transport is yours if you want it: RenderOptions::dispatch takes any
HttpDispatch, so your own client, timeouts and certificates go there. Whatever
you pass is wrapped rather than replaced, so the counting and the policy still
apply.
What a document does not carry¶
Nodes a script builds by hand with the DOM API arrive when the browser runs that
script. The render says so in response.warnings rather than leaving you to
find out, and the page is complete once the runtime has started. What a script
writes onto an element the markup declares is another matter: a class set with
set_class or set_root_class, an attribute, an inline style or text set
through a node handle is in the document the render returns, and the runtime
starts from the same values.
Components are not among them, as long as you hand the renderer the app
lumenc web compiled. Component bodies are resolved when the site is built, so
they are already markup in the artifact the renderer reads and every response
carries them. An artifact compiled some other way still holds the markers, and
those reach the document as empty elements for the browser to fill.
A component written inside a <for> is rendered per row from the state that
request settled into, so the rows and their bodies come from one run, and a
body reads in the language of the tree the request resolved to.
The rest of the limits are the emitter's, and a rendered page has the same ones a built page has.
Reporting¶
Every response carries warnings: a value a document cannot hold, a header that
was refused, an engine this build has no host for, an app that ran out of
budget, a call into a module's web half,
which raises in a render because a web half runs only in a browser. None of them stops a page from being served, and all of them are worth a
line in your log.