Libero

A full-stack Gleam framework with typed RPC. Define your handler functions, Libero generates dispatch, client stubs, and server bootstrap from the signatures. No message types to write, no dispatch to maintain.

Like server components, but your client is a real SPA with typed RPC, and the same server logic works for any client out of the box.

If you are an LLM, see llms.txt for a condensed context document.

Getting started

Read the Getting Started guide. It walks from an empty directory to a working checklist app, with every command and file shown. When you want persistent storage, follow Step 2: SQLite.

The rest of this README explains what libero is and how it works.

Project Structure

my_app/
├── bin/
│   ├── gen                          # libero codegen (dispatch + client stubs)
│   ├── build                        # build the JS client
│   ├── server                       # start the server
│   ├── dev                          # gen + build + server, in order
│   └── test                         # run server tests
├── server/
│   ├── gleam.toml                   # target=erlang, [tools.libero] config
│   └── src/
│       ├── my_app.gleam             # server entry (auto-generated, customizable)
│       ├── handler.gleam            # your RPC endpoints
│       ├── handler_context.gleam    # server context type
│       ├── page.gleam               # SSR load_page + render_page
│       └── generated/               # dispatch, websocket (auto-generated)
├── shared/
│   ├── gleam.toml                   # cross-target shared types + views
│   └── src/shared/
│       ├── router.gleam             # Route, parse_route, route_to_path
│       ├── types.gleam              # domain types used in handlers
│       └── views.gleam              # Model, Msg, view function (cross-target)
└── clients/
    └── web/
        ├── gleam.toml               # target=javascript
        └── src/
            ├── app.gleam            # Lustre client (hydrates SSR HTML)
            └── generated/           # client RPC stubs (auto-generated)

Three peer Gleam packages (server/, shared/, clients/web/), each with its own gleam.toml. Matches Lustre's recommended fullstack shape with one extension: clients/ is plural because most real apps grow more than one client. See Multiple Clients for the typical shapes.

shared/ is target-agnostic: it compiles to both Erlang (used by the server) and JavaScript (used by the client). All types crossing the wire and all view functions live here.

server/ runs gleam run -m libero to regenerate dispatch and client stubs. The bin/dev script wraps that plus gleam build and gleam run so you don't have to think about it.

Handler-as-Contract

Your handler function signatures ARE the API definition. Libero's scanner detects RPC endpoints by checking four criteria:

  1. Public function (not private)
  2. Last parameter is HandlerContext
  3. Return type is one of:
    • Result(value, error) for read-only handlers (the common case)
    • #(Result(value, error), HandlerContext) for handlers that emit a new context
  4. All types in the signature come from shared/ or are builtins

Read-only handlers return Result(_, _) directly; libero's generated dispatch threads the inbound context through unchanged. Use the tuple form only when the handler produces a new HandlerContext (login flows, session swaps, anything that mutates server state).

// server/src/handler.gleam

import gleam/list
import handler_context.{type HandlerContext, HandlerContext}
import shared/types.{
  type Item, type ItemError, type ItemParams, Item, TitleRequired,
}

// Read-only handler: bare Result.
pub fn get_items(
  handler_ctx handler_ctx: HandlerContext,
) -> Result(List(Item), ItemError) {
  Ok(handler_ctx.items)
}

// Mutating handler: tuple form. The new HandlerContext flows back into
// the session.
pub fn create_item(
  params params: ItemParams,
  handler_ctx handler_ctx: HandlerContext,
) -> #(Result(Item, ItemError), HandlerContext) {
  case params.title {
    "" -> #(Error(TitleRequired), handler_ctx)
    title -> {
      let item = Item(id: handler_ctx.next_id, title:, completed: False)
      let new_state =
        HandlerContext(
          items: list.append(handler_ctx.items, [item]),
          next_id: handler_ctx.next_id + 1,
        )
      #(Ok(item), new_state)
    }
  }
}

From these signatures, Libero generates:

The return type Result(a, e) maps directly to RpcData on the client:

Shared Types

Define your domain types in shared/src/shared/. These are the types used in handler signatures and shared between server and client:

// shared/src/shared/types.gleam

pub type Item {
  Item(id: Int, title: String, completed: Bool)
}

pub type ItemParams {
  ItemParams(title: String)
}

pub type ItemError {
  NotFound
  TitleRequired
}

Client Usage

The generated stubs let clients send typed messages. Use RpcData to track loading state. Domain errors stay typed; transport errors carry a typed RpcError:

import generated/messages as rpc
import libero/remote_data.{type RpcData, Failure, Loading, Success}
import shared/types.{type Item, type ItemError}

pub type Model {
  Model(items: RpcData(List(Item), ItemError), input: String)
}

pub type Msg {
  GotItems(RpcData(List(Item), ItemError))
  GotCreated(RpcData(Item, ItemError))
  UserToggled(id: Int)
  // ...
}

fn init(_flags) -> #(Model, Effect(Msg)) {
  #(Model(items: Loading, input: ""), rpc.get_items(on_response: GotItems))
}

In the update function, store load responses directly and use remote_data.map to update loaded data:

GotItems(rd) -> #(Model(..model, items: rd), effect.none())
GotCreated(Success(item)) -> #(
  Model(..model, items: remote_data.map(data: model.items, transform: fn(items) {
    list.append(items, [item])
  })),
  effect.none(),
)

In the view, pattern match on the four states. Use format_failure to render either error tier with one helper, supplying your own formatter for the domain side:

case model.items {
  NotAsked -> element.none()
  Loading -> html.text("Loading...")
  Failure(outcome) ->
    html.text(remote_data.format_failure(
      outcome:,
      format_domain: format_error,
    ))
  Success(items) -> view_item_list(items)
}

If transport and domain errors need different UX, drill into the outcome:

import libero/remote_data.{DomainError, TransportError}

Failure(DomainError(err)) -> format_error(err)
Failure(TransportError(rpc_err)) ->
  html.text("Connection error: " <> remote_data.format_transport_error(rpc_err))

Connection Management

The WebSocket auto-reconnects with exponential backoff (500ms to 30s with jitter) on unexpected disconnects. Pending requests reject with a connection-lost error when the socket drops. Push handlers persist across reconnects.

Hook into the connection lifecycle:

import libero/rpc

pub type Msg {
  Connected
  Disconnected(reason: String)
  // ...
}

fn init(_flags) -> #(Model, Effect(Msg)) {
  #(
    Model(..),
    effect.batch([
      rpc.on_connect(handler: fn() { Connected }),
      rpc.on_disconnect(handler: Disconnected),
    ]),
  )
}

on_connect fires on the initial connection and every successful reconnect, so loading (or reloading) state uses a single code path. on_disconnect provides a human-readable reason string suitable for display.

Configuration

All config lives in server/gleam.toml under the [tools.libero] section:

name = "my_app"
version = "0.1.0"
target = "erlang"

[dependencies]
gleam_stdlib = ">= 0.69.0 and < 2.0.0"
gleam_erlang = "~> 1.0"
gleam_http = "~> 4.0"
mist = "~> 6.0"
lustre = "~> 5.6"
shared = { path = "../shared" }
libero = "~> 5.0"

[tools.libero]
port = 8080

[tools.libero.clients.web]
target = "javascript"

Commands

From the project root:

Use bin/dev after changing handler signatures or shared types. Use bin/server alone when only handler bodies have changed.

What Gets Generated

Server-side (server/src/generated/):

Server entry point (server/src/<app_name>.gleam):

Atom registration (server/src/<app_name>@generated@rpc_atoms.erl):

Per client (clients/<name>/src/generated/):

Generation rules:

How It Works

The wire format is ETF (Erlang Term Format) over binary WebSocket frames. Gleam types serialize automatically without explicit codecs.

The client sends a typed message over the WebSocket. The server dispatch decodes it, routes by function, and calls the handler. The response flows back as Result(Result(payload, domain), RpcError), which the client stub converts to RpcData(payload, domain).

Multiple Clients

clients/ is plural because most real apps end up with more than one. Common shapes:

The handlers don't change. Each client gets typed stubs generated from the same handler.gleam signatures, so the contract stays consistent across surfaces. You can't accidentally drift the admin client's idea of Item from the web client's, because both decode the same shared/types.Item.

To add a client: create clients/<name>/gleam.toml, add [tools.libero.clients.<name>] to server/gleam.toml, then run bin/gen to generate its stubs.

Two SSR-hydrated SPAs (admin + public)

This is the question every two-app team hits: how does adding a second SPA affect the rest of the code? Here's what changes per peer.

server/ stays mostly intact.handler.gleam is still one set of RPC endpoints; both SPAs call whichever they need. handler_context.gleam doesn't change. page.gleam splits per role into admin_page.gleam and public_page.gleam, each with its own load_page and render_page pair. The server entry routes /admin/* to the admin pair and /* to the public pair, and serves both client bundles via static-file routes (/web/admin/app.mjs, /web/public/app.mjs).

shared/ splits along the UI seam. Domain types in shared/types.gleam stay unified: both SPAs decode the same Item, so wire compatibility is automatic and free. View modules and routers split per role into shared/admin/{router,views}.gleam and shared/public/{router,views}.gleam. Each gets its own Route, Model, Msg, view. Reusable widgets (a date picker, a table component) extract into shared/ui/ and get imported by both.

Why split the views? Bundle-bleed protection. Cram both UIs into one shared/views.gleam and every public visitor downloads your admin code. Splitting keeps clients/admin/'s output to admin code and clients/public/'s output to public code, with shared types and shared widgets as the bridge.

The wire contract is shared. The UI surface stays per-client.

HTTP Clients

Any BEAM process can call the server over HTTP POST without WebSocket or a Libero dependency:

// Envelope: #(module_path, request_id, ClientMsg). The request_id is
// echoed back in the 4-byte response header so concurrent calls match.
let payload = term_to_binary(#("rpc", 1, GetItems))
let assert Ok(response) = httpc.request(Post, "http://localhost:8080/rpc", payload)
let result = binary_to_term(response.body)

When to Use Libero

Libero is a good fit when:

Examples

Prior Art & Credits

Libero's JS-side ETF codec is independently implemented but aligns with arnu515/erlang-etf.js (MIT) on BIT_BINARY_EXT handling and atom-length validation. Credit to that project for clear spec references. Libero's codec adds encoding, a BEAM-native path, the float field registry, and offset-based parsing.

License

MIT. See LICENSE.