Skip to content

Commit 8b30774

Browse files
committed
distribute v3.0.0
Typed distributed messaging for Gleam on the BEAM. Core: TypedName, Codec, GlobalSubject, gen_statem actors, :global registry. 103 tests, 0 warnings.
0 parents  commit 8b30774

29 files changed

Lines changed: 3742 additions & 0 deletions

.github/workflows/test.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: test
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- uses: erlef/setup-beam@v1
14+
with:
15+
otp-version: "27.0"
16+
gleam-version: "1.6.1"
17+
- run: gleam deps download
18+
- run: gleam test
19+
- run: gleam format --check src test

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
*.beam
2+
*.ez
3+
/build
4+
erl_crash.dump
5+
**/build
6+
*.log
7+

CHANGELOG.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
## v3.0.0 — 2026-02-11
6+
7+
### ⚠️ Breaking — Complete rewrite
8+
9+
v3 is a ground-up rewrite with a much smaller API surface. If you used v2, see the migration notes below.
10+
11+
### What changed
12+
13+
- **Removed** crypto, discovery, settings, groups, monitoring, connection pool,
14+
retry, and all other modules outside the core scope.
15+
- **Removed** `whereis_global(name, encoder, decoder)` — replaced by
16+
`registry.lookup(typed_name)` with compile-time type safety.
17+
- **Added** `TypedName(msg)` — an opaque type that binds a name string to an
18+
encoder/decoder pair. Registration and lookup share the same `msg` type
19+
parameter, enforced at compile time.
20+
- **Changed** all actor functions (`start`, `start_registered`,
21+
`start_supervised`, `pool`) to accept `TypedName` instead of separate
22+
name + encoder + decoder arguments.
23+
- **Changed** `distribute.register` and `distribute.lookup` to accept
24+
`TypedName` instead of raw name strings.
25+
- **Changed** all distributed workers to be real OTP `gen_statem` behaviours
26+
via `gleam/otp/actor`, instead of custom receive loops.
27+
- **Changed** registry to use binary names with `:global` — zero atom
28+
creation, no atom table exhaustion risk.
29+
- **Fixed** shared `Nil` tag across all `GlobalSubject` — each now gets a
30+
unique or deterministic tag.
31+
- **Fixed** race condition in `start_supervised` — orphaned actors are killed
32+
on registration failure.
33+
- **Added** `Codec(a)` type — bundles encoder, decoder, and sized decoder.
34+
Shorthand constructors: `int()`, `string()`, `float()`, `bool()`,
35+
`bitarray()`, `nil()`, `list()`.
36+
- **Added** `codec.map(c, wrap, unwrap)` — derive a codec for a custom type
37+
from an existing one.
38+
- **Added** `codec.nil()` — codec for `Nil`, for actors without payload.
39+
- **Added** `composite.option(c)`, `composite.tuple2(a, b)`,
40+
`composite.tuple3(a, b, c)` — bundled `Codec` versions of composite codecs.
41+
- **Added** `registry.named(name, codec)` — shorter `TypedName` constructor
42+
that takes a `Codec` instead of separate encoder + decoder.
43+
44+
### What survived
45+
46+
- `distribute/codec` — binary serialization
47+
- `distribute/cluster` — node management
48+
- `distribute/global``GlobalSubject(msg)`
49+
- `distribute/registry` — global name registration (now with `TypedName`)
50+
- `distribute/receiver` — message reception and actor wrappers
51+
- `distribute/actor` — actor lifecycle and supervision
52+
- `distribute` — facade module.

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2025 lupodevelop
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# distribute
2+
3+
<p align="center">
4+
<img src="assets/img/distribute.png" alt="distribute logo" width="200" />
5+
</p>
6+
7+
A Gleam library for sending typed messages between nodes on the BEAM.
8+
9+
[![Package Version](https://img.shields.io/hexpm/v/distribute)](https://hex.pm/packages/distribute)
10+
[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/distribute/)
11+
12+
## Why
13+
14+
Erlang gives you distribution for free, but when you use it from Gleam you
15+
lose type information at the node boundary. The BEAM's distribution protocol
16+
sends raw terms. `distribute` wraps the moving parts (`:global`, `net_kernel`,
17+
`Subject`) behind binary codecs so the compiler can catch the obvious mistakes
18+
before your messages go over the wire. ("Typed" here means checked at
19+
encode/decode boundaries not a shared type system across nodes.)
20+
21+
It won't protect you from everything (see _Honest caveats_ below), but it
22+
makes the common path less error-prone.
23+
24+
## How it works
25+
26+
You define a `TypedName(msg)` that bundles a name string with a codec
27+
(encoder + decoder). Use the same `TypedName` to register an actor and to
28+
look it up the compiler makes sure both sides agree on the `msg` type.
29+
30+
Under the hood every actor is a regular `gen_statem` started through
31+
`gleam/otp/actor`, so `observer`, `sys:get_status` and supervision trees work
32+
as usual.
33+
34+
## Quick start
35+
36+
```sh
37+
gleam add distribute
38+
```
39+
40+
```gleam
41+
import distribute
42+
import distribute/codec
43+
import distribute/registry
44+
import distribute/receiver
45+
46+
pub fn main() {
47+
// start a node
48+
let assert Ok(Nil) = distribute.start("myapp@127.0.0.1", "secret")
49+
50+
// define the protocol — name + codec, shared across the codebase
51+
let greeter = registry.named("greeter", codec.string())
52+
53+
// start a named actor and register it globally
54+
let assert Ok(gs) = distribute.start_actor(greeter, Nil, fn(msg, _state) {
55+
io.println("Got: " <> msg)
56+
receiver.Continue(Nil)
57+
})
58+
let assert Ok(Nil) = distribute.register(greeter, gs)
59+
60+
// from any node: look up by the same TypedName and send
61+
let assert Ok(remote) = distribute.lookup(greeter)
62+
let assert Ok(Nil) = distribute.send(remote, "Hello, cluster!")
63+
}
64+
```
65+
66+
## Modules
67+
68+
| Module | What it does |
69+
|--------|-------------|
70+
| `distribute` | Facade — start, connect, send, lookup |
71+
| `distribute/actor` | Named actor lifecycle, supervision, pools |
72+
| `distribute/cluster` | Node start/connect/ping |
73+
| `distribute/codec` | Binary encoder/decoder for primitives |
74+
| `distribute/codec/composite` | Option, Result, Tuple codecs |
75+
| `distribute/codec/tagged` | Tagged messages with version checks |
76+
| `distribute/global` | `GlobalSubject(msg)` — typed wrapper around `Subject(BitArray)` |
77+
| `distribute/registry` | `TypedName(msg)`, global registration and lookup |
78+
| `distribute/receiver` | Message reception, OTP actor loops |
79+
80+
## Honest caveats
81+
82+
**What the types catch:**
83+
- Within one codebase, `TypedName` and `GlobalSubject` prevent you from mixing
84+
up message types at compile time.
85+
86+
**What they don't catch:**
87+
- Two separate projects using different codecs for the same name. The codec
88+
will reject the message at runtime, not at compile time.
89+
- Erlang code sending raw terms to a `distribute` actor. Same story — runtime
90+
rejection, no compile-time help.
91+
- The BEAM doesn't carry Gleam types across nodes. `distribute` validates via
92+
binary codecs, which is the best you can do, but it's not the same as a
93+
shared type system.
94+
95+
**Subject construction:** Gleam's `Subject` is opaque. To build one from a
96+
remote PID and a deterministic tag (which is how registry lookup works), I
97+
construct the `{subject, Pid, Tag}` tuple in our own Erlang FFI
98+
([one function](src/distribute_ffi_utils.erl)). If `gleam_erlang` changes the
99+
representation, that's the one place to update.
100+
101+
## Development
102+
103+
```sh
104+
gleam test
105+
gleam docs build
106+
```

assets/img/distribute.png

61 KB
Loading

gleam.toml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name = "distribute"
2+
version = "3.0.0"
3+
description = "Typed distributed messaging for Gleam on the BEAM."
4+
licences = ["MIT"]
5+
repository = { type = "github", user = "lupodevelop", repo = "distribute" }
6+
links = [
7+
{ title = "GitHub", href = "https://github.com/lupodevelop/distribute" },
8+
]
9+
gleam = ">= 1.4.0"
10+
11+
target = "erlang"
12+
13+
[dependencies]
14+
gleam_stdlib = ">= 0.44.0 and < 2.0.0"
15+
gleam_erlang = ">= 0.5.0 and < 2.0.0"
16+
gleam_otp = ">= 0.1.0 and < 2.0.0"
17+
18+
[dev-dependencies]
19+
gleeunit = ">= 1.0.0 and < 2.0.0"

manifest.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# This file was generated by Gleam
2+
# You typically do not need to edit this file
3+
4+
packages = [
5+
{ name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" },
6+
{ name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" },
7+
{ name = "gleam_stdlib", version = "0.67.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "6CE3E4189A8B8EC2F73AB61A2FBDE49F159D6C9C61C49E3B3082E439F260D3D0" },
8+
{ name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" },
9+
]
10+
11+
[requirements]
12+
gleam_erlang = { version = ">= 0.5.0 and < 2.0.0" }
13+
gleam_otp = { version = ">= 0.1.0 and < 2.0.0" }
14+
gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" }
15+
gleeunit = { version = ">= 1.0.0 and < 2.0.0" }

src/cluster_ffi.erl

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
-module(cluster_ffi).
2+
-export([start_node/2, connect/1, nodes/0, self_node/0, ping/1,
3+
is_ok_atom/1, get_error_reason/1, is_true/1, is_ignored/1]).
4+
5+
-import(distribute_ffi_utils, [to_atom_safe/1]).
6+
7+
%% Start a distributed BEAM node.
8+
%% Node names are created via binary_to_atom since they MUST be new atoms.
9+
%% Input is validated (max 512 bytes, no null bytes) before creation.
10+
start_node(Name, Cookie) ->
11+
NameAtom = to_atom_force(Name),
12+
Type = case string:tokens(atom_to_list(NameAtom), "@") of
13+
[_, Host] ->
14+
case lists:member($., Host) of
15+
true -> longnames;
16+
false -> shortnames
17+
end;
18+
_ -> shortnames
19+
end,
20+
try
21+
net_kernel:start([NameAtom, Type]),
22+
erlang:set_cookie(node(), to_atom_force(Cookie)),
23+
ok
24+
catch
25+
Class:Reason ->
26+
{error, iolist_to_binary(io_lib:format("~p:~p", [Class, Reason]))}
27+
end.
28+
29+
connect(Node) ->
30+
case to_atom_safe(Node) of
31+
{ok, A} ->
32+
case net_kernel:connect_node(A) of
33+
true -> true;
34+
false -> false;
35+
ignored -> ignored
36+
end;
37+
_ -> false
38+
end.
39+
40+
nodes() ->
41+
[atom_to_binary(N, utf8) || N <- erlang:nodes()].
42+
43+
self_node() ->
44+
atom_to_binary(node(), utf8).
45+
46+
ping(Node) ->
47+
case to_atom_safe(Node) of
48+
{ok, A} ->
49+
case net_adm:ping(A) of
50+
pong -> true;
51+
_ -> false
52+
end;
53+
_ -> false
54+
end.
55+
56+
%% Helpers for Gleam FFI result classification (delegate to shared utils)
57+
is_ok_atom(V) -> distribute_ffi_utils:is_ok_atom(V).
58+
get_error_reason(V) -> distribute_ffi_utils:get_error_reason(V).
59+
60+
is_true(true) -> true;
61+
is_true(_) -> false.
62+
63+
is_ignored(ignored) -> true;
64+
is_ignored(_) -> false.
65+
66+
%% Internal: create atom for node names/cookies (validated input only).
67+
to_atom_force(Bin) when is_binary(Bin) ->
68+
case is_valid_node_input(Bin) of
69+
true -> binary_to_atom(Bin, utf8);
70+
false ->
71+
try binary_to_existing_atom(Bin, utf8)
72+
catch _:_ -> binary_to_atom(Bin, utf8)
73+
end
74+
end;
75+
to_atom_force(Bin) when is_list(Bin) ->
76+
to_atom_force(list_to_binary(Bin));
77+
to_atom_force(Atom) when is_atom(Atom) ->
78+
Atom.
79+
80+
is_valid_node_input(Bin) when is_binary(Bin) ->
81+
byte_size(Bin) =< 512 andalso
82+
binary:match(Bin, <<"\0">>) =:= nomatch.

0 commit comments

Comments
 (0)