Skip to content

SSR + claim library for Lustre/Gleam/BEAM apps. It removes the flash of HTML that you get in Lustre apps by gathering the front-end state in JSON, rendering the page on the server, serving it to the browser, then the compiled JS claims the state so that the Lustre app can continue operating as usual. #ai-generated

Notifications You must be signed in to change notification settings

thirdreplicator/glamour

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Glamour

Zero-flash SSR + claim for Lustre/Gleam on the BEAM.

Glamour renders your Lustre view on the server, serialises the exact model, fingerprints the markup, and claims the live DOM on the client without wiping it. If the fingerprint changes, the client falls back to hydrate and logs helpful errors. The goal is a two-line upgrade: one call on the server, one on the client, no flash between the first paint and the claimed DOM.

Project home: https://github.com/thirdreplicator/glamour


Why Glamour

  • Zero-flash first paint – Server delivers the finished HTML and reuses it, so the user never sees an empty shell.
  • One-line ergonomicsserver.render on the backend and client.claim on the frontend.
  • Fingerprint safety – Stable SHA-256 hash over serialised state + markup keeps server/client in sync.
  • Helpful diagnostics – Clear console output for missing state, selector mismatches, and Lustre start failures.
  • Target flexibility – Works on both Erlang and JavaScript backends; incubated here prior to Hex publishing.

Installation

Incubator workflow (inside this repo)

  1. Add Glamour as a path dependency (already configured here):
    # gleam.toml
    [dependencies]
    glamour = { path = "../lib/glamour" }
  2. Fetch dependencies and build:
    gleam deps download
    gleam build --target erlang

Future Hex package (roadmap)

Once published, replace the path dependency with a semantic version:

[dependencies]
glamour = "~> 0.1"

Usage

1. Describe your Lustre app

Create an application spec that tells Glamour how to render, serialise, and decode your model. The spec assumes the Lustre app’s start_args type matches the model.

import glamour/app
import lustre
import lustre/element/html as html
import gleam/json
import gleam/dynamic/decode as decode

pub type Model {
  Model(count: Int)
}

fn init(model: Model) -> Model { model }
fn update(model: Model, _msg) -> Model { model }
fn view(Model(count:)) -> html.Node { html.text(int.to_string(count)) }

fn encode_model(Model(count:)) -> json.Json {
  json.int(count)
}

fn decode_model() -> decode.Decoder(Model) {
  decode.map(decode.int, Model)
}

pub fn spec() -> app.Spec(Model, msg) {
  let lustre_app = lustre.simple(init, update, view)
  app.new(lustre_app, view, encode_model, decode_model())
  |> app.with_selector("#app")            // optional, default is "#app"
  |> app.with_state_script_id("glamour-state") // optional, default is "glamour-state"
}

2. Render on the server

Call glamour/server.render/3 inside your HTTP handler and return the HTML string it produces. The helper embeds the SSR fragment, serialised state, fingerprint, and client script tags.

import glamour/server
import gleam/option

pub fn render_page(model) -> server.Rendered {
  let spec = spec()
  let options =
    server.default_options()
    |> with_glamour_scripts()
    |> server.Options(..)
    |> option.Some

  server.render(spec, model, options)
}

fn with_glamour_scripts(options: server.Options) -> server.Options {
  server.Options(
    ..options,
    title: option.Some("Dashboard"),
    client_scripts: ["/assets/glamour/main.mjs"],
    head: [
      ..options.head,
      "    <link rel=\"stylesheet\" href=\"/assets/app.css\">\n",
    ],
  )
}

server.Options fields:

  • lang – HTML language tag ("en" default).
  • title – Optional document title.
  • csp_nonce – Attach CSP nonce to embedded script tags.
  • head – Extra head markup (strings that already contain trailing newlines).
  • client_scripts<script> tags (module or classic) appended after the head entries.
  • stream – Reserved for future streaming support.

The returned Rendered(html) contains a complete HTML document. Render failures bubble up as normal BEAM errors.

3. Claim on the client

Bundle a small entry point that calls glamour/client.claim/2 (or /3 with options) for each page that should reuse the SSR DOM.

import glamour/client
import glam_app/dashboard
import gleam/option

@target(javascript)
pub fn main() -> Nil {
  case client.claim(dashboard.spec(), option.None) {
    Ok(_) -> Nil
    Error(error) -> handle_error(error)
  }
}

client.Options currently supports strict mode. When strict: True, a fingerprint mismatch logs an error and skips hydration; otherwise the client logs a warning and hydrates.

4. Ship the JavaScript bundle

  1. Compile the JS target:
    gleam build --target javascript
  2. Copy the generated module(s) into your static assets directory. For example:
    mkdir -p priv/static/assets/glamour
    cp build/dev/javascript/myapp/myapp/glamour/client_bundle.mjs priv/static/assets/glamour/main.mjs
    rsync -a build/dev/javascript/glamour priv/static/assets/glamour/
    Adjust the paths to match your app name and deployment pipeline.
  3. Reference the bundle in the client_scripts list so the browser loads it:
    <script type="module" src="/assets/glamour/main.mjs"></script>

Source Layout

  • src/glamour/* – Target-agnostic modules used on both Erlang and JavaScript (server rendering, fingerprints, spec helpers).
  • src-js/glamour/* – JavaScript-only modules (client.gleam, dom.gleam, dom.ffi.mjs). Keeping them in src-js means you do not need to move files between targets; Gleam automatically picks the right version during compilation.

Contracts & Assumptions

  • Your Lustre App must accept its model as start_args; Glamour passes the reclaimed model directly into lustre.start.
  • The view used on the server must match the one supplied to lustre.start or fingerprints will diverge.
  • JSON encoders/decoders must round-trip the model losslessly. If parsing fails the client logs JsonParse and leaves the SSR DOM untouched.
  • The DOM selector in Spec.selector must resolve to the root element rendered by the server. A missing selector logs MissingRoot.
  • Glamour embeds the state as <script type="application/json" id="{state_script_id}">. Do not mutate or rename this node on the server.
  • Fingerprints rely on the serialised JSON and raw HTML fragment. Any server-side post-processing (e.g., analytics scripts) should wrap, not mutate, the <div> Glamour controls.

Error Handling

  • Server render failures bubble up as normal exceptions; let your HTTP framework surface them or wrap the call to return a diagnostics page.
  • Client errors return Result(Nil, client.Error) and log to the console:
    • NotInBrowser – Guard for server-side or test environments.
    • MissingRoot, MissingStateScript, MissingFingerprint – Misconfiguration signals.
    • JsonParse, LustreStart – Data/initialisation issues.
    • FingerprintMismatch – Fingerprints differ; hydration fallback or strict abort.

Use the error constructors in tests to assert the correct behaviour.


Conventions

  • Selector defaults to #app; state script id defaults to glamour-state.
  • Script tags inserted by server.Options.client_scripts should include trailing newlines, matching how Gleam concatenates head elements.
  • Client bundle entry points live under @target(javascript) modules (e.g., src-js/your_app/glamour/client_bundle.gleam).
  • When incubating inside another project, keep build artefacts in priv/static/assets/glamour/ so they can be served by Mist or Plug.

Example: MyApp Admin

A typical integration renders admin routes with Glamour:

  • Server specs: src/myapp/glamour/login.gleam, src/myapp/glamour/admin.gleam
  • Client bundle: src-js/myapp/glamour/client_bundle.gleam/assets/glamour/main.mjs
  • Your HTTP handler returns the Glamour-produced HTML document.

Run the developer server with:

gleam run --target erlang --module main -- server

Testing

Server-side tests:

gleam test --target erlang

Client-side behaviour can be smoke-tested by loading the bundled app in a browser; upcoming work will add harness tests around client.claim.


Roadmap

  • Streaming SSR support (Options.stream)
  • Dev overlay with pretty error rendering
  • Hex package metadata & publish checklist
  • Production-ready asset pipeline (tree-shaken JS bundle)

Released under the MIT licence.

About

SSR + claim library for Lustre/Gleam/BEAM apps. It removes the flash of HTML that you get in Lustre apps by gathering the front-end state in JSON, rendering the page on the server, serving it to the browser, then the compiled JS claims the state so that the Lustre app can continue operating as usual. #ai-generated

Resources

Stars

Watchers

Forks

Packages

No packages published

Contributors 2

  •  
  •