Protocol 1
C ABI and the foreign gateway
native/c is a client library, not a NIF. It speaks TCP to Wiregrid.Foreign.Gateway. A fault in the C, Rust, Zig, Go, Python, Java, or .NET process stays in that process. The client never sees ETS table ids, pids, or Erlang external term layout. A subscribed C client on general is on the same channel as MyApp.Chat.join(session, "general").
Framing
The socket is Erlang gen_tcp with packet: 4: a 4-byte big-endian length, then one frame. Inside the frame:
| Bytes | Field |
|---|---|
| 1 | version, currently 1 |
| 1 | kind: 0 request, 1 response, 2 event |
| 1 | operation |
| 4 | request id, unsigned big-endian |
| rest | TLV fields |
A field is one tag byte, a 4-byte big-endian length, and the value. Protocol 1 allows 32 fields, 1,048,576 bytes per field, and 2,097,152 bytes per frame. Duplicate tags are {:duplicate_field, tag}. A mismatched version is {:unsupported_protocol, version}.
Operations
| Name | Code |
|---|---|
| hello | 1 |
| subscribe | 2 |
| unsubscribe | 3 |
| publish | 4 |
| ack | 5 |
| presence | 6 |
| join_room | 7 |
| leave_room | 8 |
| ping | 9 |
| history | 10 |
| event | 64 |
Requests use kind 0. Deliveries arrive as asynchronous frames with operation 64 and kind 2. There is no client-chosen session id on these calls. The connection’s hello creates one Wiregrid session, and every later op uses it.
Field tags
| Name | Tag |
|---|---|
| instance | 1 |
| user_id | 2 |
| topic | 3 |
| payload | 4 |
| content_type | 5 |
| delivery_id | 6 |
| status | 7 |
| room | 8 |
| event_id | 9 |
| error | 10 |
| class | 11 |
| server_version | 12 |
| session_id | 13 |
| auth_token | 14 |
| cursor | 15 |
| limit | 16 |
Topic text uses the chat names: a bare general is {:channel, "general"}, thread:42 is {:thread, "42"}, channel:i:7 is {:channel, 7}, and custom:ops:desk is {:custom, "ops", "desk"}. The token beam-only and an a: atom id are refused on input so a client cannot create atoms. join_room takes the room id as a plain string, so lobby matches an Elixir join_room of "lobby".
application/json bodies are decoded into chat event maps (type, body, message_id, and the other chat fields). Any other content type is delivered as %{type: :foreign, payload: ..., content_type: ...}. Events produced on the BEAM are encoded as JSON on the way out, so a C client can read a normal say. history checks the :read authorizer and returns JSON {"events":[...]} plus a cursor field. Durable publishes are stored before delivery. Ephemeral publishes are not stored.
Running the gateway
children = [
MyApp.Chat,
{Wiregrid.Foreign.Gateway,
instance: :chat,
port: 9567,
max_connections: 2_000,
authenticate: fn user_id, token -> MyApp.ForeignAuth.check(user_id, token) end}
]
Defaults: ip {127,0,0,1}, port 0 (the OS picks a port; read it with Wiregrid.Foreign.Gateway.port/1), max_connections 1,024 (range 1..100,000), idle_timeout_ms 60,000 (1,000..3,600,000), send_timeout_ms 5,000 (100..120,000), max_protocol_errors 8 (1..100). allow_anonymous: true installs a function that accepts every hello, and only when the bind address is loopback. On any other address the option raises. authenticate is a function of arity 2. Return :ok or true to accept. C ABI major is 1, the same generation as Wiregrid.c_abi_major/0.
Listen options include packet: 4, binary, nodelay, reuseaddr, a backlog of min(max_connections, 4096), and packet_size set to the 2 MiB frame cap. The gateway does not wrap the socket in TLS.
C client
wg_client *client = NULL;
wg_client_options options;
wg_client_options_init(&options);
options.auth_token = getenv("WIREGRID_TOKEN");
if (wg_connect("127.0.0.1", 9567, "alice", &options, &client) == WG_OK) {
wg_subscribe(client, "general");
wg_publish(client, "general", "hello", 5, "text/plain", 1, NULL, 0);
wg_close(client);
}
Header wiregrid.h defines WG_ABI_MAJOR 1, WG_ABI_MINOR 1, WG_PROTOCOL_VERSION 1, and WG_DEFAULT_EVENT_QUEUE 1024. Results are WG_OK (0) and WG_ERR_ARGUMENT, WG_ERR_MEMORY, WG_ERR_IO, WG_ERR_PROTOCOL, WG_ERR_REMOTE, WG_ERR_TIMEOUT, WG_ERR_OVERFLOW, WG_ERR_CLOSED (negative codes from -1 through -8).
Calls: wg_connect, wg_connect_ex (writes an error buffer), wg_close, wg_subscribe, wg_unsubscribe, wg_publish (payload, content type, durable flag, optional event-id buffer), wg_ack, wg_set_presence, wg_join_room, wg_leave_room, wg_ping, wg_history, wg_poll, wg_event_free, wg_last_error, wg_result_string, wg_protocol_version, wg_abi_major, wg_abi_minor. wg_history with limit 0 asks the server for its default page. The output buffer receives {"events":[...]}. wg_poll with timeout 0 does not block.
One owner drives a handle. There is no hidden thread. While a request waits, async deliveries sit in a bounded queue. If that queue fills, the client closes and returns WG_ERR_OVERFLOW rather than dropping a delivery and leaving ack state ambiguous. wg_client_options has timeout_ms, event_queue_capacity, and auth_token.
make -C native/c
make -C native/c test
curl -fsSL https://raw.githubusercontent.com/Plainwire-development/Wiregrid/v1.0.0/scripts/install.sh | sh
pkg-config --exists wiregrid
cc -o chat chat.c $(pkg-config --cflags --libs wiregrid)
The install lands include/wiregrid.h, libwiregrid.a, libwiregrid.so or .dylib, and lib/pkgconfig/wiregrid.pc. Wrappers in other languages should keep the C structs private, copy event bytes out before wg_event_free, and make close safe to call twice.