beryl is a Gleam library for type-safe real-time channels and presence on the Erlang/BEAM runtime. It's a trellis-managed monorepo with three releasable packages, plus runnable examples and an Astro/Starlight documentation website.
just deps # install all deps (gleam + pnpm workspaces)
just check # type check all packages
just test # run all tests (scope: `just test beryl_mist`)
just build-strict # build with Gleam warnings as errors
just format # format all packages
just format-check # check formatting (CI uses this)
just lint # glinter with warnings_as_errors
just beam-check # Dialyzer and Xref checks for Erlang FFI
just ci # full CI: format-check, BEAM analysis, tests, docs, examples
just pr # alias for ci — run before creating/updating PRs
just main # just ci plus documentation checks
just change P K "B" # create changelog fragment (trellis changelog new)
just doctor # validate workspace invariantsScope tests to one package with just test beryl_mist, or run a package
directly with cd packages/beryl && gleam test. unitest supports filters after
--, such as gleam test -- --test module_name.test_name or
gleam test -- --tag slow.
just test runs the parallel-safe core tests with unitest's automatic worker
count, capped at four BEAM schedulers to avoid nested oversubscription, then
runs tests tagged serial in a sequential lane. Tag a core test serial when
it observes or changes process-wide state, such as :telemetry handlers, the
Palabres capture handler, or Palabres's global logging level, or when it
deliberately exercises VM-sensitive restart exhaustion or PubSub scope
recovery. Also tag tests whose correctness depends on tightly controlled actor
restart or delayed-ack scheduling. Direct gleam test remains a full
sequential run.
Do not run just check before a same-scope build or test; both already
type-check. Use just build-strict when Gleam warnings must fail the check.
packages/
beryl/ # core library (app dispatch, presence, pubsub, wire, transport SPI)
beryl_mist/ # Mist WebSocket transport (depends on beryl via path)
beryl_ewe/ # Ewe WebSocket transport (depends on beryl via path)
examples/ # runnable example apps (workspace members, excluded from release)
website/ # Astro/Starlight docs site (NOT a workspace member)
docs/ # ADRs, design docs, architecture deck, security docs
Transport packages (beryl_mist, beryl_ewe) MUST only import from the
public beryl/transport SPI module. They must never import:
beryl/connection_limitberyl/internalberyl/logberyl/rate_limitberyl/runtimeberyl/telemetry
These modules are declared as internal_modules in packages/beryl/gleam.toml
and are hidden from the public API. Functions in beryl/transport that are
consumed by transport packages carry // nolint: unused_exports annotations
because they appear unused within beryl itself.
beryl/channel MUST stay layered on beryl's public core APIs. Do not
import beryl/runtime or the other internal modules listed above. Keep its
private routing machinery behind the module's internal seam, and preserve the
raw-dispatch/channel Phoenix parity matrix in packages/beryl/test/.
beryl.child_spec captures the app's generic model, msg, init, and
update types in monomorphic closures behind the opaque Sockets handle. The
runtime stores each socket's typed model and delivers Join, Message,
Binary, Closed, and typed Info events without socket-dispatch casts or
Dynamic round-trips. Dynamic is limited to decoded wire payloads.
JoinRef values are single-pending-join capabilities with unique runtime
identity. Never reconstruct or compare their wire fields; return the exact ref
in AcceptJoin or RejectJoin.
A transport implementation must follow this lifecycle:
- Admit: call
transport.acquire_connection_slot+transport.bind_connection_slot - Own: capture
transport.connection_ownerand monitor anOwnerAlivepid - Register atomically: call
transport.admit_socketwith the captured owner and closer; close on failure - Route: decode inbound frames with
transport.active_codec, route viatransport.route_decoded/transport.route_binary, shed over-rate frames withtransport.new_message_limiter/transport.take_token - Disconnect:
transport.socket_disconnected+transport.release_connection_slot
The built-in codec (wire.phoenix_codec()) implements the Phoenix wire format.
beryl.config takes the codec as a required argument — there is no implicit
default.
- Text frames:
[join_ref, ref, topic, event, payload]JSON arrays - Binary frames: Phoenix V2 binary framing
- Heartbeats: topic
"phoenix", event"heartbeat"; replied to withphx_replyon the same topic
PubSub broadcasts are sent raw between BEAM nodes via pg as the scope
atom followed by the four pubsub.Message(payload) fields. This five-element
tuple is a frozen wire contract. The scoped and previously unscoped shapes do
not interoperate during a rolling upgrade.
Configuration types (Config, LoggingConfig, PubSubConfig, and presence
Config) are opaque. Construct them with their factory functions and adjust
them with with_* builders. Never expose or match on their internal fields.
This is the most critical testing gotcha. Tests that run near PubSub, heartbeat, or WebSocket assertions must:
- Select the exact message shape they expect
- Consume or drain messages they create
- Never use broad "any message" selectors — a stale queued message from a prior test can cause nondeterministic failures
Use test_helper.wait_until(check, timeout_ms, interval_ms) instead of
process.sleep(N) for asynchronous assertions (presence replication, broadcast
delivery, etc.).
- Tests use unitest as the runner and
gleeunit/shouldfor assertions - Public socket/event behavior changes need integration coverage through the runtime and transport paths, not only pure helper tests
- When replacing WebSocket transports, preserve existing Phoenix/WebSocket contract coverage by repurposing tests rather than deleting them
Test directories contain .erl files for test-only FFI helpers (e.g.,
beryl_log_capture.erl, beryl_supervisor_test_ffi.erl,
beryl_mist_transport_test_ffi.erl). These provide BEAM-level capabilities
not available in pure Gleam.
Treat Erlang FFI declarations as representation boundaries. Do not
identity-coerce an external tuple into a Gleam custom type unless it already
has the exact Gleam constructor tag and field layout. Return a tuple and
construct the value in Gleam, or use a small Erlang converter. Run
just beam-check after changes to Erlang FFI or the Gleam code that declares
it.
- Result types over exceptions — all fallible APIs return
Result - No recoverable-path assertions — released library code must not use
let assert, panic, or check-then-assert for recoverable or fallible paths; match and propagate the error or use the established logging and recovery path - Exhaustive pattern matching — Gleam enforces this; handle all cases
- Sentence-case website headings — use sentence case for page titles, section headings, callout titles, and sidebar labels
- Lowercase product name — write the product name as
berylin prose, including at sentence starts and in headings; preserve exact code identifiers and quoted text ///doc comments on all public functions@internalannotation — hides functions from public docs while keeping them accessible to sibling modules within the same package- App dispatch changes — follow the existing event and effect handling
patterns in
beryl/eventandberyl/runtime - glinter is enforced with
warnings_as_errors = true. Key rules:thrown_away_error,discarded_result,unused_exports,deep_nesting,prefer_guard_clause,error_context_lost,unqualified_import,stringly_typed_error,short_variable_name,string_inspect
For code reviews that include Gleam (.gleam) or Erlang (.erl, .hrl),
delegate the BEAM-specific review to beam-vm-reviewer, unless you are
already running as that agent. Include every changed Gleam and Erlang file
in the review scope, including tests and examples. This review supplements
normal correctness and API review; it does not replace them.
Edit .apm/agents/beam-vm-reviewer.agent.md, then run
apm install --frozen --only apm to deploy it. apm.yml selects both Claude
and Copilot. APM generates .github/agents/beam-vm-reviewer.agent.md and
.claude/agents/beam-vm-reviewer.md; do not edit those copies directly.
To request a review in either tool, ask:
Use beam-vm-reviewer to review the current diff.
Required GitHub Actions jobs must always start and report success. When path-filtering, skip only expensive steps and run a successful no-op step; do not skip the whole required job.
Before creating or updating a PR, fetch the current default branch and validate the branch against it. PR CI tests the merge result, so local checks on an outdated branch can miss failures.
Types: feat, fix, docs, style, refactor, perf, test, build,
ci, chore, release, revert. Keep type/scope lowercase.
Header max 72 chars. Body max line length 100 chars.
Create a fragment with just change <package> <kind> "<body>". Kinds:
Initial Release (major), Added/Changed/Removed (minor),
Fixed/Performance/Deprecated/Security/Dependencies (patch).
A fragment applies only to what a released package ships — its src/,
gleam.toml, or manifest.toml. The fragment describes what a consumer of
the published package receives. Write a fragment when you change a released
package's:
- public API (types, functions, labels, or signatures)
- observable runtime behavior, or a bug in that behavior
- dependency requirements in
gleam.tomlormanifest.toml ///or////doc comments, because these ship in the API reference
Do not write a fragment for anything a package does not ship. The website,
docs/, examples, and dev tooling reach no consumer, so a fragment for them
bumps a version that nobody sees:
website/content, components, styles, or generator scriptsdocs/ADRs, design notes, and decksexamples/(release-excluded workspace members)- root config,
justfile,.github/workflows, and audit or lint settings packages/*/test/, internal refactors, and formatting that keep the consumer-visible behavior the same
All three packages under packages/ are releasable, so each one takes its own
fragments. Only examples/** is release-excluded.
PR CI enforces fragments via trellis changelog check. Preview the effect of
the current fragments with just version-plan.
- Push commits with conventional messages + changelog fragments
trellis release prmaintains a release PR (branchrelease/pending)- Merge release PR → per-package tags (
beryl-v1.2.3) + GitHub releases - Hex.pm publishing is temporarily disabled
Do NOT stage: generated Playwright output, test-results/ directories,
erl_crash.dump, temporary PR body files. Example Playwright runs create
untracked artifacts — clean before staging.
.mise.toml pins Erlang 28, Gleam 1.18.1, just 1.50.0, trellis, rebar3, and
licence_audit for local development and CI.
CI additionally matrix-tests Erlang 27 and 28 (see .github/workflows/ci.yml).
- Never declare the same package in both
[dependencies]and[dev-dependencies] - Keep shared requirements in the runtime dependency with the stricter range
- When changing dependency files, update
gleam.tomlandmanifest.tomlconsistently with Gleam tooling (don't hand-edit one side)
Only examples/** is excluded from release (@release key in the root
gleam.toml). Examples are built and tested, but they get no changelog,
version, tag, or fragment. All three packages under packages/ are
releasable on the git_only lifecycle: they get tags and GitHub releases,
but Hex.pm publishing is disabled.
Examples are workspace members with their own gleam.toml, package.json,
and Playwright configs. They depend on beryl via path. The collab_docs/client
is a nested workspace member (a JS client). Examples without a test/
directory are excluded from trellis run test in gleam.toml.