# Libero Context Libero is a full-stack Gleam framework with typed RPC. Write handler functions with the right signature, and Libero generates dispatch, client stubs, and server bootstrap from them. Transport is WebSocket with ETF encoding; you don't write REST endpoints, JSON codecs, manual dispatch, or message types. ## Project Structure ``` my_app/ ├── bin/ │ ├── dev # codegen + run server │ └── test # run server tests ├── server/ │ ├── gleam.toml # target=erlang, [tools.libero] config │ └── src/ │ ├── my_app.gleam # server entry (auto-generated, customizable) │ ├── handler.gleam # pub functions = RPC endpoints │ ├── handler_context.gleam # server context type │ ├── page.gleam # SSR load_page + render_page │ └── generated/ # dispatch, websocket (auto-generated) ├── shared/ │ ├── gleam.toml # target-agnostic package │ └── src/shared/ │ ├── router.gleam # Route, parse_route, route_to_path │ ├── types.gleam # domain types (shared between server + client) │ └── views.gleam # Model, Msg, view function (cross-target) └── clients/ └── web/ ├── gleam.toml # target=javascript └── src/ ├── app.gleam # Lustre SPA (hydrates SSR HTML) └── generated/ # client stubs (auto-generated) ``` Three peer Gleam packages (`server/`, `shared/`, `clients/web/`), each with its own `gleam.toml`. `shared/` has no target, so it compiles to both Erlang and JavaScript. This lets both the server and JS clients import the same domain types without the client pulling in Erlang-only dependencies. ``` server/ -> shared, libero, mist [target: erlang] clients/web/ -> shared, libero, lustre [target: javascript] ``` Rules: - `server/src/` is server code. Handlers, context, and business logic live here. - `shared/src/shared/` holds domain types and views shared across targets. - `clients//` are consumer apps, each a separate Gleam package. - Never edit files in `generated/` directories. - Each client's `gleam.toml` is written once during scaffold, never overwritten. ## Commands Scaffold a new project: ```bash curl -fsSL https://raw.githubusercontent.com/pairshaped/libero/master/bin/new | sh -s my_app cd my_app bin/dev ``` From the project root: - `bin/gen`: regenerate libero codegen (dispatch, client stubs) - `bin/build`: build the JavaScript client bundle - `bin/server`: start the server - `bin/dev`: convenience wrapper that runs gen, build, server in order - `bin/test`: run server tests `bin/dev` runs in order: `cd server && gleam run -m libero` (codegen), then `cd clients/web && gleam build --target javascript`, then `cd server && gleam run`. To add a client manually: create `clients//gleam.toml`, add `[tools.libero.clients.]` to `server/gleam.toml`, then run `bin/dev`. ## Configuration Libero config lives in `server/gleam.toml` under `[tools.libero]`: ```toml 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" ``` ## Handler-as-Contract Your handler function signatures ARE the API definition. Libero's scanner detects RPC endpoints by checking four criteria: 1. Public function 2. Last parameter is `HandlerContext` 3. Return type is one of: - `Result(value, error)` for read-only handlers - `#(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 (the common case) return `Result(_, _)` directly. Generated dispatch threads the inbound context through unchanged. Use the tuple form when the handler produces a new `HandlerContext` (login flows that swap the session, anything that mutates server state). ```gleam // 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: bare Result. Dispatch reuses the inbound context. pub fn get_items( handler_ctx handler_ctx: HandlerContext, ) -> Result(List(Item), ItemError) { Ok(handler_ctx.items) } // Mutating: tuple form. The new HandlerContext flows back to 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) } } } pub fn toggle_item( id id: Int, handler_ctx handler_ctx: HandlerContext, ) -> #(Result(Item, ItemError), HandlerContext) { // ... } pub fn delete_item( id id: Int, handler_ctx handler_ctx: HandlerContext, ) -> #(Result(Int, ItemError), HandlerContext) { // ... } ``` From these signatures, codegen produces: - `ClientMsg` type: `GetItems`, `CreateItem(params: ItemParams)`, `ToggleItem(id: Int)`, `DeleteItem(id: Int)` - Dispatch module routing each variant to its handler function - Typed client stubs: `rpc.get_items(on_response: GotItems)`, `rpc.create_item(params: .., on_response: GotCreated)` The return type `Result(a, e)` maps directly to `RemoteData` on the client: - `Ok(value)` becomes `Success(value)` - `Error(err)` becomes `Failure(err)` (typed domain error, not a string) ## Shared Types Define domain types in `shared/src/shared/`. These are the types used in handler signatures: ```gleam // 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 } ``` Rules: - Only types in `shared/` (or builtins like `Int`, `String`, `List`, `Result`) can appear in handler signatures. - Functions using server-only types are automatically excluded from codegen. - File layout is free. Libero scans for exported types, not file paths. ## Client Usage (Lustre) ```gleam // clients/web/src/app.gleam import generated/messages as rpc import libero/remote_data.{type RpcData, Failure, Loading, Success} import shared/types.{type Item, type ItemError, ItemParams} 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) UserDeleted(id: Int) } pub fn init(_flags) -> #(Model, Effect(Msg)) { #(Model(items: Loading, input: ""), rpc.get_items(on_response: GotItems)) } ``` Common patterns: - Generated stubs return a Lustre `Effect`. Wire encoding handled internally. - Each stub takes an `on_response` callback that wraps the decoded `RemoteData` in your `Msg` type. - `RpcData(value, domain)` keeps domain *and* transport errors typed: `Failure(DomainError(NotFound))`, `Failure(TransportError(error.MalformedRequest))`. - Store `RpcData` in your model. Assign load responses directly: `GotItems(rd) -> Model(..model, items: rd)`. - Use `remote_data.map` to update loaded data: `remote_data.map(data: model.items, transform: fn(items) { ... })`. - Pattern match the four states in the view: `NotAsked`, `Loading`, `Failure(outcome)`, `Success`. Use `remote_data.format_failure(outcome:, format_domain:)` to render either error tier with one branch. Only drill into `Failure(DomainError(...))` / `Failure(TransportError(...))` when transport and domain errors need distinct UX. ### Connection lifecycle The WebSocket auto-reconnects with exponential backoff (500ms → 30s, full jitter) on unexpected disconnects. Hook in via `libero/rpc`: - `rpc.on_connect(handler:)`: fires on initial connect AND every successful reconnect. Use it to load (or reload) state without separate code paths for the first connection. - `rpc.on_disconnect(handler:)`: fires when the connection drops; the reason string is human-readable and intended for UX ("offline" indicator, "reconnecting..."). Pending requests at disconnect time reject with a connection-lost error rather than waiting. Push handlers persist across reconnects. ## BEAM Clients (CLI, Services) Any BEAM process can call the server over HTTP POST with native ETF: ```gleam // 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) ``` No WebSocket, no generated stubs, no Libero dependency needed. Push is WebSocket-only. ## Server-Side Rendering (SSR) SSR lets the server pre-render the initial page HTML with real data so the browser displays content immediately. No loading spinner, no blank page while JavaScript boots. The client then hydrates the pre-rendered DOM (attaches event handlers, connects WebSocket) and takes over as a normal SPA. Users see content faster, and search engines see fully rendered pages. The `libero/ssr` module provides four functions: ### `ssr.call` -- fetch data on the server Calls `dispatch.handle` directly (no network) to fetch data for rendering: ```gleam import libero/ssr let assert Ok(counter) = ssr.call( handle: dispatch.handle, handler_ctx:, module: "rpc", msg: GetCounter, expect: fn(resp) { let assert Ok(n) = resp n }, ) ``` ### `ssr.encode_flags` / `ssr.decode_flags` -- pass state to client Server encodes a value as base64 ETF. Client decodes it in `init`: ```gleam // Server: encode for embedding in HTML let flags = ssr.encode_flags(counter) // Client: decode in Lustre init fn init(flags: Dynamic) -> #(Model, Effect(Msg)) { let counter = case ssr.decode_flags(flags) { Ok(n) -> n Error(_) -> 0 } // ... } ``` ### `ssr.handle_request` -- route, load, and render a page Handles a full server-side render cycle: parse the URI into a route, load data, render to HTML, and return a `Response`. Non-GET requests get a 405. ```gleam pub fn handle_request( req req: Request(body), parse parse: fn(Uri) -> Result(route, Nil), load load: fn(Request(body), route, handler_ctx) -> Result(model, Response(ResponseData)), render render: fn(route, model) -> Element(msg), handler_ctx handler_ctx: state, ) -> Response(ResponseData) ``` Usage in the server's mist router: ```gleam ssr.handle_request( req:, parse: router.parse_route, load: page.load_page, render: page.render_page, handler_ctx:, ) ``` ### `ssr.boot_script` -- embed flags and boot the client app Returns a lustre fragment of two `