libero/etf/wire

Wire codec for Libero transport.

Libero owns the protocol boundary. Consumers should use the high-level frame API (encode_response, decode_server_frame, encode_push, decode_push_frame) and not depend on the wire shape.

The current wire format is ETF (Erlang Term Format). Encoding walks any Gleam value through erlang:term_to_binary/1, which preserves the full Erlang type structure natively. Decoding uses erlang:binary_to_term/1 to reconstruct the original terms.

Wire shape:

Cross-target: encode and decode work on both Erlang and JavaScript targets. The Erlang path uses the BEAM’s native term_to_binary / binary_to_term. The JavaScript path uses libero’s own ETF encoder/decoder in wire_ffi.mjs, which requires that any custom-type constructors in the value have been registered via the generated decoders.gleam module (which surfaces ensure_decoders from the FFI). Libero’s generator emits that registration for every type reachable from caller-provided seeds.

Types

pub type ServerFrame(value) =
  frame.ServerFrame(value)

Values

pub fn decode(data: BitArray) -> a

Decode an ETF binary into an arbitrary Gleam value.

Works on both Erlang and JavaScript targets. Use this for non-transport paths - for example, reading server-rendered state from Lustre flags on client boot. For decoding incoming transport request envelopes specifically, use decode_request instead.

Any custom types in the decoded value must be reachable from the seeds supplied to Libero so their constructors are registered with the JavaScript codec via the generated decoders.gleam module. On Erlang this is automatic because atoms are pre-registered by the generated libero_atoms module.

Warning: type safety is the caller’s responsibility. The return type a is unwitnessed: the function returns whatever the ETF binary deserializes to, cast to the caller’s expected type. A version skew between client and server will produce silent data corruption, not a runtime error. This is an intentional tradeoff for ergonomics in controlled deployments where both sides are built from the same source.

This is by design: the generated code is the enforcement point.

Panics on malformed input. In a typical libero deployment both sides are controlled, so this is a sharp-edge check rather than a user-facing error. For untrusted input, use decode_safe which returns a Result.

pub fn decode_flags_typed(
  flags flags: String,
  decoder_name decoder_name: String,
) -> Result(a, error.DecodeError)

Decode a base64 ETF flags string and apply a typed decoder. Combines base64 decode, ETF decode, and typed decoder application into a single call. Used client-side during hydration to reconstruct typed values from SSR flags without touching raw decode helpers.

The decoder_name is the function name in the generated decoder registry, e.g. "decode_pages_home__model".

pub fn decode_push_frame(
  data: BitArray,
) -> Result(frame.ServerFrame(dynamic.Dynamic), error.DecodeError)

Decode a push frame into its module and payload value. Strips the frame tag byte, then ETF-decodes the {module, value} tuple from the payload. Returns the result as a ServerFrame.

Prefer decode_server_frame unless you know the frame is a push and want to skip the tag-byte dispatch.

pub fn decode_request(
  data: BitArray,
) -> Result(#(String, Int, dynamic.Dynamic), error.DecodeError)

Parse a {<<"module_name">>, request_id, payload} tuple from an ETF binary. Returns the module name, request ID, and the raw Dynamic value to be coerced. Since binary_to_term returns real Erlang terms, no rebuild step is needed - atoms are atoms, tuples are tuples, maps are maps.

This is specifically for transport request envelopes. For decoding arbitrary values, use decode.

pub fn decode_response_frame(
  data: BitArray,
) -> Result(frame.ServerFrame(dynamic.Dynamic), error.DecodeError)

Decode a response frame into its request ID and payload value. Strips the frame tag byte and request ID header, then ETF-decodes the payload. Returns the result as a ServerFrame.

Prefer decode_server_frame unless you know the frame is a response and want to skip the tag-byte dispatch.

pub fn decode_safe(
  data: BitArray,
) -> Result(a, error.DecodeError)

Decode an ETF binary into an arbitrary Gleam value, returning a Result instead of panicking on malformed input.

Use this for non-transport paths where the input may be untrusted or user-influenced - for example, reading server-rendered state from Lustre flags on client boot where the binary may have been corrupted in transit.

On Erlang this rejects malformed ETF, new atom creation, and trailing bytes. It does not run Libero’s full non-executable term validator by default because that double-walks every payload; generated typed dispatch remains the normal enforcement point.

pub fn decode_server_frame(
  data: BitArray,
) -> Result(frame.ServerFrame(dynamic.Dynamic), error.DecodeError)

Decode any server-to-client frame (response or push) into a ServerFrame. This is the primary entry point for consumers: hand Libero the raw bytes and pattern-match on the result.

The tag byte (0 = response, 1 = push) is read and dispatched internally. Consumers never inspect frame bytes.

pub fn decode_typed(
  data data: BitArray,
  decoder_name decoder_name: String,
) -> Result(a, error.DecodeError)

Decode an ETF binary and apply a typed decoder by name.

On JavaScript, this does the two-pass decode: raw ETF → typed decoder lookup via the registry populated by generated decoders_ffi.mjs. The decoder_name is the full function name, e.g. "decode_pages_home__model".

On Erlang, ETF is BEAM-native so the decoder_name is ignored; binary_to_term already reconstructs all types correctly.

pub fn encode(value: a) -> BitArray

Encode any Gleam value to an ETF binary.

Not safe for user custom types. This calls encode_term which is a container-only walker: it recurses into lists, maps, and tuples but passes atoms through unchanged. User custom type constructors go over the wire as bare BEAM atoms, not hashed wire identities.

For user values, use the typed entry points instead:

  • encode_response for transport handler returns
  • Generated encode_push/2 pre-encoder before wire.encode_push frames server-initiated messages
  • Per-type generated pre-encoders before encoding SSR flags

This function is correct for primitives, containers of primitives, and values that have already been pre-encoded by a typed encoder.

pub fn encode_flags(value: a) -> String

Encode a value to a base64 ETF string for embedding in HTML. Used server-side during SSR to serialize the page model or client context into <script> tags for client hydration.

pub fn encode_push(
  module module: String,
  value value: a,
) -> BitArray

Encode a push message as a frame (tag byte 1, {module, value} tuple as ETF payload). This is the combined version of encode + tag_push. Prefer this over assembling the tuple and frame by hand.

pub fn encode_request(
  module module: String,
  request_id request_id: Int,
  msg msg: a,
) -> BitArray

Encode a request envelope: {module_name, request_id, msg} as ETF binary. Used by framework-generated request senders to pack their payload for transport to the server.

Encode an outbound transport request as ETF.

pub fn encode_response(
  request_id request_id: Int,
  value value: a,
) -> BitArray

Encode a value and wrap it in a response frame (tag byte 0, 32-bit request ID). This is the combined version of encode + tag_response. Prefer this over calling the two functions separately.

pub fn js_term_depth_limit() -> Int

Returns the active JavaScript ETF term depth cap, or 0 when disabled.

Always returns 0 on Erlang.

pub fn set_js_term_depth_limit(limit: Int) -> Nil

Configure the optional JavaScript recursive ETF term depth cap.

A positive integer enables the cap. 0 or a negative number disables it. The default is disabled for generated browser clients because they usually decode ETF from the same trusted app server that served the JS bundle.

Use 512 for Libero’s default cap when decoding ETF from a less trusted source. On Erlang this is a no-op because the BEAM decoder owns ETF parsing.

pub fn set_strict_data_terms(enabled: Bool) -> Nil

Enable or disable strict BEAM data-term validation for ETF decode.

This affects Erlang decode_safe and decode_request. When enabled, Libero rejects decoded BEAM runtime terms such as pids, refs, ports, and functions before generated typed decoding runs.

The default is disabled because this recursively walks the full decoded payload before typed dispatch walks legitimate requests again. Enable it when accepting hand-written ETF from untrusted non-Libero clients.

On JavaScript this is a no-op because BEAM runtime terms do not exist.

pub fn strict_data_terms_enabled() -> Bool

Returns whether strict BEAM data-term validation is enabled.

Always returns False on JavaScript.

pub fn tag_push(data: BitArray) -> BitArray

Tag a push frame. Server-initiated messages use tag byte 1 with no request ID, since there is no originating call to correlate.

pub fn tag_response(
  request_id request_id: Int,
  data data: BitArray,
) -> BitArray

Tag a response frame so the JS client routes it to the correct callback. Prepends a 0 tag byte and the 32-bit request ID so the client can correlate the response with the originating call.

Search Document