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:
- Request envelope:
{module_name_binary, request_id, request_msg_value}- a 3-tuple ETF payload wrapped in a request frame. - Response frame: tag byte 0, 32-bit request ID, ETF payload.
- Push frame: tag byte 1, ETF payload (
{module, value}tuple).
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_responsefor transport handler returns- Generated
encode_push/2pre-encoder beforewire.encode_pushframes 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.