Compiled Server-Side Rendering¶
domonic.compile(view) inspects a Python function at startup and builds a
specialized HTML renderer. Literal markup is folded into strings; dynamic text
and attributes remain Python expressions. Supported request paths construct no
DOM nodes. Renderers stay in memory during requests. An optional disk bytecode cache can
reuse generated code across server restarts.
Definition-time decorator¶
For a standalone view, @compiled is convenience syntax for
home = compile(home). It compiles immediately when Python defines the function,
so even the first call uses the prepared renderer. No application, lifespan,
route registry or renderer dictionary is needed.
from domonic import compiled
from domonic.dom import DOMConfig
from domonic.html import html, body, h1, p
DOMConfig.GLOBAL_AUTOESCAPE = True # set before defining compiled views
@compiled
def home(name="World"):
return html(body(h1("Hello"), p(name)))
print(home())
Run the tiny, framework-free example with
python -m examples.ssr.decorated_view. The advanced server example remains
available separately as examples.ssr.compiled_views.
The default is memory-only. @compiled(strict=True, cache_dir="/tmp/domonic")
passes those settings directly to the existing compiler, including its private
directory requirements. @compiled() also works. Metadata and the inspectable
signature are preserved; home.__original__ (also home.original and
home.__wrapped__) references the uncompiled function. is_compiled,
fallback_reason, source and cache_hit remain available.
Escaping, supported syntax, fallback and error behavior are identical to
compile(view). Unsupported views fall back by default; use strict=True
to require compilation at import time. Async views remain unsupported, matching
the existing compiler. The explicit compile(view) API is unchanged.
A working example¶
from types import SimpleNamespace
from domonic import compile
from domonic.dom import DOMConfig
from domonic.html import div, h1, p
DOMConfig.GLOBAL_AUTOESCAPE = True
def profile(user):
return div(h1("Profile"), p(user.name, _class="bio"))
compiled = compile(profile, strict=True) # startup, before serving requests
print(compiled(SimpleNamespace(name="Alice & Bob")))
# <div><h1>Profile</h1><p class="bio">Alice & Bob</p></div>
print(compiled.source) # generated Python for review/debugging
assert compiled.original is profile
Run the FastAPI/Uvicorn example with
python -m examples.ssr.compiled_views, then open http://127.0.0.1:8000/.
The example provides /, /profile, /dashboard and /large routes.
It requires the optional fastapi and uvicorn packages.
Functions must have inspectable source (normally a .py file). Lambdas and
functions defined with exec typically fall back. Compilation never calls the
view or re-evaluates its defaults or decorators.
Escaping follows normal domonic serialization: attributes are escaped, while
text follows DOMConfig.GLOBAL_AUTOESCAPE (False by default). Enable it for
untrusted SSR text. Script/style content remains raw, as in normal rendering;
entity escaping does not make arbitrary values safe JavaScript or CSS.
Configuration changes after compilation cause ordinary view rendering until
recompilation. Do not change global rendering settings during requests.
Routes¶
For individual routes, register the compiled callable. Decorator order matters:
@app.route("/profile")
@compile
def profile():
return div(h1("Profile"))
For applications with a Flask-style view_functions dictionary, the optional
mixin supplies app.compile():
from flask import Flask
from domonic.ssr import CompiledRoutes
class App(CompiledRoutes, Flask):
pass
app = App(__name__)
@app.route("/")
def home():
return div(h1("Hello"))
app.compile() # after registering all routes, before starting the server
for endpoint, renderer in app.compiled_views.items():
print(endpoint, renderer.is_compiled, renderer.fallback_reason)
Only supported handlers are replaced. Unsupported handlers retain their exact original behavior, including framework response objects and tuples. This mixin is not a universal adapter for FastAPI/Starlette or other route registries; register compiled views explicitly with those frameworks.
Supported code and fallback¶
The initial compiler supports directly imported standard HTML constructors
(including aliases), scalar expressions, simple assignments, if/else,
conditional expressions, nested list/generator comprehensions over supplied
iterables, and explicit list/tuple children. Comprehensions generate HTML
strings, not intermediate DOMs. Static text, attributes, and adjoining tag
boundaries are folded together.
Unknown function/method calls, DOM mutation, imperative loops, custom elements,
expanded *args/**kwargs, wrapped functions and complex statements use
normal rendering. Async functions are not supported by this API. Use
strict=True to reject unsupported views at startup; otherwise inspect
is_compiled and fallback_reason. Runtime exceptions propagate without
retrying the view, so side effects are not duplicated.
Scalar child values can also be callables: they are invoked at rendering time, matching normal domonic behavior. A callable or supplied value that returns an existing DOM uses ordinary serialization for that subtree. The no-node-creation guarantee applies to compiled markup; callbacks that create their own DOMs are outside that guarantee. Keep expressions and comprehensions free of side effects: compilation combines construction and serialization, so their interleaving can differ from constructing an entire tree before rendering it.
Existing DOMs¶
compile(existing_dom) creates a snapshot of concrete markup. Later mutations
do not affect the snapshot. Callable children remain dynamic and are not called
during compilation. Keep DOMConfig fixed for these snapshots; compile again after
changing it. Custom nodes or iterator children conservatively fall back to normal
serialization (or raise with strict=True).
Cold-render benchmark¶
Run python scripts/benchmark_ssr.py --repeats 5. Every sample starts a fresh
Python process and measures its first render, with no warmup. Imports, input
fixtures and compilation happen before the request timer; startup compilation
is reported separately. Output parity is checked after timing. This measures
view-to-HTML latency, not HTTP/server startup or network time.
On the development machine (Python 3.13), five fresh-process samples per mode produced these medians. These are examples, not cross-machine guarantees:
View |
Normal (ms) |
Compiled (ms) |
Speedup |
Compilation (ms) |
|---|---|---|---|---|
Small |
0.068 |
0.008 |
8.5x |
0.624 |
Medium |
1.436 |
0.118 |
12.2x |
1.090 |
Large |
29.561 |
2.333 |
12.7x |
1.200 |
The fixtures render 78, 7,811 and 139,919 UTF-8 bytes respectively. Normal rendering includes both DOM construction and serialization; neither side uses a response cache. First-request savings exclude startup compilation, which is intentional: compile before accepting requests.
Disk cache and first-request startup¶
compiled = compile(profile, strict=True, cache_dir="/tmp/my-private-ssr-cache")
print(compiled.cache_hit)
cache_dir is optional on compile(view), compile(DOM), and the
CompiledRoutes.compile() mixin. The directory is created with mode 0700;
an existing directory must also be private and owned by the current user.
The cache contains executable Python bytecode: use an application-owned directory,
not a shared or untrusted folder. Entries are written atomically, keyed by the
generated source, cache format and Python version. Corrupt entries are rebuilt.
Only code and static markup are stored, never runtime globals or request data.
Deleting the directory simply causes compilation on the next startup.
This caches the final Python bytecode compilation step. Source inspection and AST validation still run at startup, preserving current bindings and fallback checks. During requests, the callable is already in memory: there is no disk lookup, compilation or response-cache warmup.
The example uses /tmp/domonic-ssr-example-<uid> and compiles all four views in
FastAPI’s startup lifespan. Uvicorn waits for that lifespan to yield before
accepting requests. The routes only invoke app.state.renderers[...]. Starting
the example again prints disk-cache hits, and the very first request uses the
compiled renderer in both runs. strict=True makes unsupported markup fail
startup rather than silently serving an uncompiled example.
Explicit trusted HTML¶
from domonic.html import div, p, raw
def notice(user_text, trusted_html):
return div(p(user_text), raw(trusted_html))
raw(value) explicitly marks trusted HTML child content. It works in normal
serialization, compiled views, and DOM snapshots. It creates a string marker,
not a DOM node, and does not sanitize its input. Leave user input unmarked.
Attribute values still escape normally, even when given a raw marker. In the
server example the query parameter is escaped; only the application-owned
<strong> notice is marked raw.
How it works under the hood¶
The compiler specializes the work normally split between constructing a tree and serializing it. A normal call builds element objects, sets up their children and attributes, then walks them to produce HTML. A supported compiled view emits the same markup directly from Python expressions.
At definition time or explicit compilation¶
inspect.getsourceretrieves the view’s source. The compiler dedents it and parses it withast.parse. It reads the function’s globals and closure bindings to identify standard HTML constructors by identity, rather than assuming every function nameddivis a domonic tag. Shadowed names and unknown calls are handled conservatively.The AST pass checks the supported subset before producing a renderer. It does not execute the original view, runtime expressions, defaults or decorators. Mutation-heavy or otherwise unsupported views become normal rendering wrappers, or raise
UnsupportedViewwithstrict=True.Element calls become opening tags, attributes, child expressions and closing tags. Literal text and attributes are serialized under the current settings; adjacent literal chunks are merged. This is markup folding, not arbitrary evaluation of application code at compile time.
Dynamic expressions remain in the generated function. Temporary bindings preserve child-before-attribute argument evaluation even though attributes appear first in the output. Conditionals retain their branch tests, and comprehensions join rendered strings instead of constructing element lists.
Python’s built-in
compileproduces bytecode, andexeccreates the specialized function in a namespace containing the view’s bindings and serialization helpers. Original defaults and keyword defaults are restored without evaluating their expressions again. A wrapper preserves function metadata and performs the runtime compatibility checks described below.
For example:
def profile(user):
return div(h1("Profile"), p(user.name, _class="bio"))
The generated renderer has this shape (the helper name below is shortened for readability):
def profile(user):
return (lambda value:
f'<div><h1>Profile</h1><p class="bio">{text(value)}</p></div>'
)(user.name)
The complete tag structure is a literal string around one dynamic value.
text applies normal domonic child serialization, including configured
escaping. Dynamic attributes use a separate attribute serializer; they cannot
accidentally inherit the trusted-text behavior of raw. Inspect the actual
code with renderer.source rather than relying on this illustrative spelling.
On each request¶
The wrapper checks that rendering configuration still matches the compilation settings, refreshes referenced global and closure bindings, and checks that recognized constructor bindings have not been replaced. A configuration change or constructor rebinding sends that call through the original view and normal serialization. Otherwise the generated function evaluates current arguments and returns HTML immediately. This is why the same compiled function can render many different users without caching their responses.
@compiled invokes this entire preparation process while the function is
defined and returns the wrapper immediately. It is not a lazy initializer.
The FastAPI example explicitly performs preparation in its startup lifespan
instead; both arrangements prepare the first request in advance.
What the caches contain¶
The in-memory artifact is a callable with reusable Python code and literal
markup. There is no automatic memoization by request arguments. With
cache_dir, the compiler additionally persists generated bytecode using
marshal in private, atomically replaced files. The key hashes the generated
source, cache-format version and Python version/ABI. A checksum detects damaged
entries; directory and file ownership/permissions restrict who can supply the
executable cache contents. A checksum is not a substitute for that trust boundary.
A disk hit skips the final source-to-bytecode step. It still inspects and
transforms the current view at startup to establish its generated source,
current bindings and compatibility checks. Dynamic globals, closures and request
arguments are supplied by the current process, not deserialized from disk.
Static markup is part of the cached code, including concrete markup supplied to
compile(existing_dom). No disk read occurs on the request path.
A DOM snapshot starts from an existing tree rather than function source. It folds the tree’s concrete markup and keeps callable children as dynamic inserts. The snapshot deliberately does not track later tree mutations. Neither approach turns arbitrary Python into a pure template: runtime DOM values and callbacks can still require subtree serialization, and side-effectful expressions should use ordinary rendering when their construction/serialization ordering matters.
Localisation with gettext¶
Run python -m examples.ssr.localised_view for a framework-free example with
bundled French and German gettext catalogs and English source messages. It
compiles one template, then renders English, French, German and English again:
page.is_compiled stays True and the template is never recompiled.
from functools import partial
from domonic import compiled
from domonic.html import div, h1
@compiled(strict=True)
def page(hello):
return div(h1(hello))
# fr is a gettext.translation(...) catalog, loaded outside the template.
print(page(partial(fr.gettext, "Hello world")))
partial binds the message but does not translate it yet. The callable child
is invoked when the compiled renderer runs, so the catalog can change between
renders. Ordinary text escaping still applies to the translated result.
A direct h1(_("Hello world")) inside the view currently uses an unsupported
runtime function call: it falls back to normal rendering, or raises with
strict=True. Passing a callable child as above keeps the view compiled.
Alternatively, translate before calling the template and pass the translated
string as a normal dynamic argument. The example’s .po sources and matching
.mo files live in examples/ssr/locales; it needs no external service or
translation package.