Web Deployment
Introduction
WIP — M8.7c. Server-side rendering, the HTTP server, and a minimal client-side WASM dispatch path all work today. The hello-world on_click callback runs as remill-lifted WebAssembly in the browser and increments a counter that lives in shared wasm linear memory. More complex apps still fall back to server-side execution; the production roadmap is in scripts/HACKS_REVIEW_2026_05_16.md.
The same binary that opens a desktop window can run as an HTTP server. Set the environment variable and azul replaces the platform shell with a TCP listener:
AZ_BACKEND=web://127.0.0.1:8080 ./my-azul-app
The layout callback runs natively in the server process. Its output is rendered to HTML with a <style> block built from the resolved CSS cascade, then served at /. Images and fonts are extracted and served under /az/. Per-callback WASM modules are lifted from the running binary's .text via remill and served under /az/cb/.
To enable client-side WASM dispatch, build the dll with the web-transpiler feature:
cargo build -p azul-dll --release --features "build-dll web web-transpiler"
Without web-transpiler, callbacks fall back to POST /az/exec/{node_id} server-side execution as in earlier releases.
AZ_BACKEND URL format
Accepted forms:
web://127.0.0.1:8080. Localhost only.web://0.0.0.0:3000. All IPv4 interfaces.web://[::1]:8080. IPv6 loopback.web://0.0.0.0:443?tls=cert.pem. Query string is ignored today.
The web:// prefix is case-insensitive. Anything after ? is reserved for future flags and silently dropped. A malformed value falls back to the desktop shell.
What runs server-side, what runs client-side
- Layout callback (
Domconstruction). Runs on the server. Works. - CSS cascade. Runs on the server. Works.
- HTML serialization. Runs on the server. Works.
- Image and font collection. Runs on the server. Works.
- Browser layout and paint. Runs on the client. Works (the browser does it).
:hoverand:focusstyling. Runs on the client. Works (CSS only).- RefAny hydration into wasm memory. Runs on the client at bootstrap. Works for simple data shapes (single u32 in hello-world).
on_clickcallback dispatch. Runs on the client as remill-lifted WASM. Works for hello-world.mousemove/keydown/focus/resizeetc. dispatch. Not yet wired (onlyclickis inazWireListeners).- DOM diff + patch after
Update::RefreshDom. Not yet wired (loader.js uses a hardcodedtextContent =shortcut on the counter node).
Azul's full cascade resolves all conditional rules on the server (theme, viewport, container, language). Only interactive pseudo-states remain as CSS rules in the served stylesheet. The browser handles those without a round-trip.
The wasm-side dispatch path uses a hydration payload embedded in the rendered HTML's <head>:
<script id="az-hydrate" type="application/json">
{"type_id":"4298653512","json":5}
</script>
The bootstrap reads it, allocates a wasm-side AzRefAny via AzStartup_hydrate, and passes the resulting pointer to every callback invocation. Mutations the cb makes to the user data are observable from JS via DataView(memory.buffer).getUint32(modelPtr) and applied back to the DOM. Today the JSON payload is restricted to a single number (hello-world's counter); arbitrary user data needs the lifted _fromJson path (M8.8 work — see internals).
Routes
Routes use :param segments. Pass them via AppConfig.routes:
let routes = vec![
Route { pattern: "/users/:id".into(), layout_callback: user_page() },
Route { pattern: "/products/:sku".into(), layout_callback: product_page() },
];
Without any routes, the root window's layout callback becomes /. The server tries exact match first, then parameterized match, then falls back to /.
Asset URL layout
Every URL under /az/ is generated and served by the framework:
/and route patterns. Pre-rendered HTML, no cache header./az/loader.js. Bootstrap script (inline-embedded in the HTML today)./az/mini.{hash}.wasm. Mini wasm (~5 KB, sixAzStartup_*entry points),immutable, max-age=1y./az/cb/{name}.{hash}.wasm. Per-callback wasm (~14 KB for hello-world; size scales with the transitive lift closure),immutable, max-age=1y./az/layout/{name}.{hash}.wasm. Per-layout-callback wasm. Preloaded today but not yet instantiated by the loader./az/img/{id}. PNG-encoded image,immutable, max-age=1y./az/font/{id}. TTF font bytes,immutable, max-age=1y.POST /az/exec/{node_id}. Server-side callback dispatch fallback. Not invoked by the current loader (the wasm path is always tried first).
{hash} is a content hash so a build refresh invalidates the browser's cache automatically. POST bodies are capped at 16 MB; oversized payloads return 413 Payload Too Large.
CSS emission
Azul runs the cascade server-side and emits one rule per node:
#az_0 { display: flex; flex-direction: column; padding: 12px; }
#az_1 { color: #333; font-size: 14px; }
#az_1:hover { color: #000; }
Base styles come from the resolved cascade (every condition already resolved). Pseudo-state overrides (:hover, :focus, :active, :checked, :disabled) are emitted as separate selectors. The browser does the rest.
Bundled fonts in AppConfig.bundled_fonts are emitted as @font-face rules pointing at /az/font/{id}. The server only emits fonts it has the bytes for; system font fallback is not exposed to the browser.
The loader script
The bootstrap (loader_js.rs::generate_m8_loader, inline-embedded
in the HTML rather than served separately) does:
- Mini wasm bootstrap. Fetches
/az/mini.<hash>.wasm, instantiates with a sharedWebAssembly.Tablefor indirect callback dispatch, callsAzStartup_initto allocate the eventloop state. - Hydration. Reads
<script id="az-hydrate">, allocates the user data + callsAzStartup_hydrateto build a wasm-sideAzRefAnyin shared linear memory. Saves the resulting pointer (azRefAnyPtr) for callback invocation. - Per-callback instantiation. For each
[data-az-cb] [data-az-wasm]element, fetches the cb wasm and instantiates it withenv.memory = mini.exports.memoryso both modules share linear memory. - Click wiring. Installs a
clicklistener ondocument.bodythat walks the target's id chain foraz_N, looks up the matching cb fn, invokescbFn(BigInt(azRefAnyPtr), 0n, infoPtr). If the cb returnsUpdate::RefreshDom(1), reads the mutated counter from wasm memory and applies atextContent =update to the matching DOM node. - SPA-style navigation. Internal
<a href="/...">clicks are intercepted and routed throughfetch()plushistory.pushState. Asset URLs (/az/...) are excluded. - Back/forward. A
popstatelistener re-fetcheslocation.pathname.
The DOM diff/patch, mousemove dispatch, and full event chain via
AzStartup_dispatchEvent are next-session work — see
internals/web.md.
Server-side fallback
The POST /az/exec/{node_id} route from earlier releases is still
implemented and runs the cb natively, returning a full HTML
replacement. The current loader does not invoke it (the wasm
path always wins on supported builds). It remains available for:
- Apps that need to bypass the wasm path on a per-cb basis.
- Browsers that fail to instantiate the cb wasm (the loader logs a warning and the cb becomes a silent no-op rather than falling back automatically).
- Manual testing via
curl -X POST http://localhost:8080/az/exec/3.
A future bootstrap version will fall back to POST automatically when wasm instantiation fails.
Deploying
The server has no built-in TLS, no auth, and no load balancing. It is a standalone HTTP listener with Connection: close semantics. For a public deployment, front it with a reverse proxy (nginx, Caddy, traefik):
[client] ──HTTPS──> [Caddy/nginx] ──HTTP──> [azul app on 127.0.0.1:8080]
A minimal Caddyfile:
my-app.example.com {
reverse_proxy 127.0.0.1:8080
encode gzip
}
Bind to 127.0.0.1 (not 0.0.0.0) when behind a proxy on the same host. Bind to 0.0.0.0 only inside a container or behind an infrastructure firewall. Port 0 is not supported.
Resource considerations:
- Memory: the server keeps the entire pre-rendered HTML, all collected images, and all collected fonts in RAM. A multi-route app with large images can grow quickly.
- Concurrency: one OS thread per connection. Fine for tens of concurrent users; for more, run multiple processes behind the proxy and share state externally.
- State: callback-heavy workloads serialize through a single mutex on the shared app data.
Browser support: the served HTML is plain HTML5 + ES5; the wasm
path additionally requires WebAssembly.instantiateStreaming +
BigInt (Safari 14+, Chrome 85+, Firefox 78+ — all evergreen
browsers from 2020 onward).
Coming Up Next
- Headless Rendering — Running the pipeline without a window
- Security Model — What azul does and doesn't defend against
- Code Generation — How
azul-docregenerates bindings fromapi.json