Rust servers
A Rust backend can serve a Gyral app without a Node server. Rust keeps what it already owns: routes, sign-in, permissions, data and every write. Gyral owns the components: their HTML, their behaviour in the browser and, when the server renders them, their hydration.
Two questions decide how the two fit together:
- Who writes the document? Gyral at build time, Rust templates per request, or Gyral per request inside the Rust process.
- Does each component arrive rendered? A server-rendered component hydrates in place: the browser takes over its HTML and state. A component that arrives as a bare tag renders for the first time in the browser.
The answers give three paths. Each one runs the same components:
| Path | Document by | Components |
|---|---|---|
| Build-time pages | Gyral, at build | Hydrate |
| Rust templates | Askama | Render in place |
| Embedded rendering | Gyral, in Rust | Hydrate |
Only the third path runs JavaScript on the server, and only it needs
gyral-rs. The first two use Gyral exactly as this
documentation describes it, with Rust as the web server.
The examples on this page use one counter. It takes its starting count as a prop, so Rust can set it from a template attribute or from render data:
// src/counter.ts
import { define, html, prop } from '@gyral/core';
export interface Props {
readonly start: number;
}
export type Msg = { readonly _tag: 'Increment' };
export const Counter = define<{ readonly count: number }, Msg, Props>()('my-counter', {
props: { start: prop.number({ default: 0 }) },
init: (props) => ({ count: props.start }),
intent: { Increment: true },
update: { Increment: (s) => ({ count: s.count + 1 }) },
view: (s, i) => html`
<output aria-live="polite">${s.count}</output>
<button type="button" data-intent=${i.Increment}>Increment</button>
`,
});The browser entry is the same on every path. It registers the components; Gyral then hydrates the ones the server rendered and renders the rest:
// src/client.ts
import './counter.js';Build-time pages and a Rust API
Choose this path when a page is the same for every visitor, or when its personal data can load
after the page does. Prerender the pages as you would for any static
host. Rust then serves the output folder as files and answers the API routes the components
call, such as /api/notes.
Rust needs nothing from Gyral at runtime. Two things carry over from the build:
- The Content Security Policy. A static build computes it ahead of time, style hashes included (Headers and CSP). Write it to a file Rust reads at startup, and send it with every page.
- Cache headers. Content-hashed files under
/assets/never change, so serve them with a longmax-ageandimmutable. Pages need revalidation (Cache headers).
Serve the pages and the API from one origin, so components call the API with relative URLs and
a policy limited to 'self' covers them.
Rust templates with browser components
Choose this path when ordinary HTML can carry a page's main job and its components can wait for JavaScript. Rust renders the whole document with its own templates, so native forms post to Rust and work with JavaScript turned off. Gyral adds interactive parts on top.
The template writes each component as a tag with ordinary attributes and readable fallback
content. With Askama, whose {{ }} escapes each value:
<my-counter start="{{ start }}">
<p>Starting count: {{ start }}. The button appears when JavaScript loads.</p>
</my-counter>
<script
type="module"
src="{{ client.path }}"
integrity="{{ client.integrity }}"
crossorigin="anonymous"
></script>When the client entry loads, define() upgrades the tag, reads start from its attribute and
renders the counter into the component's shadow root. The browser stops showing the fallback
then. There is nothing to hydrate: the server sent a tag, not the component's HTML. Each
attribute is parsed and validated like any prop,
so a bad value falls back to the prop's default.
Build the client with your usual bundler (the example uses esbuild). Rust serves its hashed
files and sends a policy that allows them. Components that render in the browser adopt their
styles as constructed stylesheets, so style-src 'self' is enough; style hashes are only for
server-rendered components. Components that read form fields, such as a live preview, observe
the native controls with a subscription; the form still submits to
Rust.
The gyral-rs Askama example
is a complete notes app on this path: Axum routes, CSRF checks, SQLite, redirects after
writes, and a Gyral preview beside each form. It doesn't link the embedded renderer.
Embedded rendering with gyral-rs
Choose this path when a page that changes per request must arrive with its components already
rendered, and hydrate in the browser. gyral-rs runs your app's own Gyral server bundle in an
embedded V8 inside the Rust process. Node builds the bundles; the running server doesn't need
it.
gyral-rs is experimental. Version 0.4 accepts bundles built with Gyral 0.3.6 exactly,
and its tested target is Linux x86_64 (GNU) with Rust 1.99. Each gyral-rs minor accepts one
Gyral release, so check its changelog
before you upgrade Gyral in a Rust-served app.
The server bundle
The bundle is one script that sets two globals. Rust calls gyralRender with a page name and
the JSON props it approved; the function returns the page's HTML:
// src/server.ts
import { html } from '@gyral/core';
import { renderToString } from '@gyral/ssr';
import './counter.js';
// The build replaces these with the hashed client file and its integrity hash.
declare const __CLIENT_PATH__: string;
declare const __CLIENT_INTEGRITY__: string;
interface RenderInput {
readonly page: string;
readonly props: { readonly start: number };
}
declare global {
var gyralBundleVersion: number;
var gyralRender: (input: RenderInput) => Promise<string>;
}
globalThis.gyralBundleVersion = 1;
globalThis.gyralRender = ({ page, props }) => {
if (page !== 'home') throw new Error(`Unknown page: ${page}`);
return renderToString(
html`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Counter</title>
<script
type="module"
src=${__CLIENT_PATH__}
integrity=${__CLIENT_INTEGRITY__}
crossorigin="anonymous"
></script>
</head>
<body>
<main><my-counter .start=${props.start}></my-counter></main>
</body>
</html>`,
{ onError: 'throw' },
);
};It uses renderToString, not renderPage: the embedded
runtime has no Fetch API, so the bundle writes its own document shell and returns a string.
onError: 'throw' turns a failing component into a failed render, which Rust answers with an
error page instead of a page with a hole in it.
The runtime is not Node and not a browser. It has no DOM, file system, network or TextEncoder,
and Gyral's style hashing needs TextEncoder. The
example's server entry
installs a small UTF-8 encoder first; copy it.
Building the pair
Build the browser and server bundles together, from the same components and the same exact Gyral version:
- Browser: one self-contained ES module from
src/client.ts, named by its SHA-256 hash. - Server: one IIFE from
src/server.tswith every import bundled in,platform: 'neutral', and the client's path and integrity hash defined into it. - Manifest:
manifest.jsonwith the bundle ABI (1), the Gyral version, both hashes and the style hashes fromstyleHashes().
The renderer loads no modules at runtime, so split chunks and dynamic import() are not
supported. The example's build script
does all three steps with esbuild in about fifty lines.
The Rust side
Add the crate with its Axum adapter (cargo add gyral --features axum). At startup, verify the
three files as one set and start a renderer:
let artifacts = Arc::new(AppArtifacts::try_new(manifest, server_js, client_js)?);
let policy = DocumentPolicy::try_new(artifacts.manifest())?;
let (renderer, worker) = Renderer::start(artifacts.bundle().clone())?;A handler loads the data, checks access, and renders with only what the browser may see:
let request = RenderRequest::try_new(PageId::try_from("home".to_owned())?, json!({ "start": 5 }))?;
let response = gyral::axum::render_page(&renderer, request).await.into_response();
policy.apply(response)DocumentPolicy sends an enforcing CSP that allows the verified client file and the
manifest's style hashes. Each render runs in a fresh V8 isolate with a deadline and a memory
budget. When the server stops, drain HTTP first, then worker.shutdown().await joins the
render threads. The crate's README and the
Axum example cover the
details: limits, overload, health checks and the native V8 build.
Mixing paths
An app can use different paths for different pages. Its marketing pages can be prerendered, its forms Askama, and its dashboard rendered per request, all served by one Rust process. Build each path's components from the same source and the same Gyral version.
Within one page, the bundle contract also allows gyralRender to return a fragment instead of
a document, which an Askama page could include so that some components hydrate and others
render in the browser. No gyral-rs example or test covers this yet. If you try it, the
template has to add what the embedded document adds itself: the client script with its
integrity hash, and a policy with every component's style hashes.
What Rust must own
Whichever path you choose:
- Approve every value that reaches Gyral. Props, attributes and API responses become page HTML and, when a component is server-rendered, hydration seeds the browser can read. Never put secrets or fields the visitor may not see into them.
- Send Gyral's HTML unchanged. Don't rewrite it or write seeds yourself; Gyral escapes values and owns the hydration format.
- Send a complete CSP that allows the client files and style hashes. See Security for the policy Gyral needs.
- Treat writes as final on the server. A component's request can be cancelled in the browser after Rust committed it, so a redraw is never an authorization check.
- Run only your own bundles. The embedded renderer's limits bound time and memory; they don't make it a sandbox for untrusted JavaScript.