bead 0.10.0 · spec 0.11.0 · layouts 0xA1 and 0xB1
Bead
A binary protocol for calls between a client and a server, in both directions.
Try it live A page with a button for each kind of call, each call logged with its envelopes, against a server that runs in a Web Worker of the page.
The essentials
Bead is a binary protocol for calls between a client and a server. A request receives its result or its failure, a subscription its notifications, and the server can call its client in turn. A large payload goes in chunks, and a call can be cancelled on the way. It all fits in one small envelope, on any transport that delimits its messages. Here is a whole request, byte by byte.
- prefix, the same for every kind
- the kind’s own
- an optional block
- a failure
- the payload
- An envelope of 8 bytes at fixed offsets, then the payload.
- A callId pairs each answer with its call, in both directions.
- A failure carries a code whose block says the stage it reached.
- Subscriptions at the client’s pace, and transfers under credit.
- A credential on each call, which relays forward.
- One behaviour in every language, played from the same conformance files.
- 8-byte prefix
- 12 bytes for a request
- payloads past one envelope
- 23 failure codes
- 407 units in the spec
- no required dependency on the TypeScript side
Each section gives its gist first. The detail is folded under a triangle, and the
specification, spec/spec.md, keeps the
whole text: references such as §12 point at its sections.
Bead’s choices
Bead serves where a client and its server hold a conversation. The server pushes changes, a large file goes up while other calls carry on, a call stops halfway, and the server sometimes asks the client something.
Three aims guide each choice. A decoder written by hand reads each field at its place, parsing nothing. A payload is bounded only by the limits of both ends. A refusal says how far the request got.
| Choice | What Bead does | What it gives |
|---|---|---|
| The unit | Raw bytes behind an 8-byte prefix | A codec written by hand, with no serialization framework |
| The length | None in the header: the transport delimits the envelope | No ceiling, and a large payload goes as a transfer |
| The fields | One field a byte, or an aligned word | Nothing to mask or to shift |
| The failure | Once, in the payload of a FAILURE | One place to read it |
| The codes | In blocks of 16, by the stage reached | A peer that does not know a code knows its stage |
| The header | 12 bytes for a request, 16 for a chunk or a credit, 8 for the others | A size known from the kind and the flags |
Bead takes its ideas from public protocols. The transport delimits the messages, as WebSocket does. An odd or an even callId says who opened the call, as HTTP/2’s streams do. Failure codes go in blocks, as HTTP’s status classes do.
The envelope travels over WebSocket, over HTTP where WebSocket cannot pass, through a Web Worker in the page, through memory between two routers of one process, or across an MQTT 5 or AMQP 1.0 broker. Both ends keep the same API, whatever the transport. gRPC-Web and Connect carry unary calls and server streams to a browser; Bead also carries the server’s calls to its client, and subscriptions at the client’s pace.
A service is declared in a .proto, and beadc
generates its server and its client. What is left to write
is the resolvers and the calls, here abridged from
examples/hello:
// the server: its admission hooks, by the names the .proto gives them, and a resolver that asks
// its caller back before it answers
const hello = new HelloServer({ admissions: { greeted } });
hello.on('sayHello', async ({ connectionHandle, payload }, response) => {
const name =
payload.name === ''
? (await hello.command.askName({ question: 'What is your name?' })({ connectionHandle })).name
: payload.name;
return response({ greeting: `Hello, ${name}!` });
});
hello.offer(server);
// the client: answers the server's command, calls, and follows a subscription at its own pace
await using client = createWebSocketClient();
const greeter = new HelloClient(client, { windows: { countDown: 1 } });
greeter.on('askName', (_request, response) => {
return response({ name: 'Ada' });
});
await client.connect({ host: '127.0.0.1', port });
const { greeting } = await greeter.sayHello({ name: '' })(); // 'Hello, Ada!'
await greeter.notifications.countDown({ from: 3 }, async ({ remaining }) => {
// a window of 1: the next tick waits until this callback settles
})();
The envelope
Every envelope opens with the same 8-byte prefix. The kind and the flags add fields at fixed offsets, then the payload runs to the end of the message.
In network order, the prefix holds the layout (0xA1), the kind, the flags, the
header’s length in 4-byte words, and the callId on 4 bytes. A REQUEST adds its service and its
procedure, 2 bytes each.
The flags of the low nibble add blocks, in the order of their bits. DESTINATION
(0x02) names a relay’s target in 4 bytes. TRANSFER (0x04) gives a
transfer’s total in 8 bytes. CREDENTIAL (0x08) comes last, the only one of
variable length: a scheme, a length, the bytes, then zeros up to the next word, like a TLS
extension. The flags of the high nibble add nothing: SUBSCRIPTION (0x10), PACED
(0x20) and MORE (0x40). The bits 0x01 and
0x80 are reserved. A header holds 1,020 bytes at most (§2 to §4).
bytes 0-3bytes 4-7bytes 8-11DESTINATIONTRANSFER 1/2TRANSFER 2/2CREDENTIAL 1/5CREDENTIAL 2/5CREDENTIAL 3/5CREDENTIAL 4/5CREDENTIAL 5/5| Kind | Byte | Header | Its payload |
|---|---|---|---|
REQUEST |
0x10 | 12 bytes | What the procedure receives |
RESULT |
0x11 | 8 bytes | What the procedure answers |
FAILURE |
0x12 | 8 bytes | The failure: code, relays, scope, detail |
NOTIFICATION |
0x13 | 8 bytes | What the subscription announces |
CHUNK |
0x14 | 16 bytes, with its offset | Bytes of a transfer |
CREDIT |
0x15 | 16 bytes, with the end granted | None |
CANCEL |
0x16 | 8 bytes | None |
A peer dispatches every envelope it can read by its kind. A REQUEST passes its gates, then goes to its procedure. A RESULT or a FAILURE settles the call waiting on that callId. A NOTIFICATION goes to that callId’s subscription. A CHUNK takes its place in its transfer, and a CREDIT moves a transfer being sent, or a paced subscription, forward. A CANCEL makes the peer forget the call. A command, the server’s request to its client, is just a REQUEST on an even callId.
Three more envelopes, byte by byte
A RESULT that announces a session
The flag 0x08 adds the CREDENTIAL block, here OPAQUE and a 16-byte token.
A REQUEST that opens a subscription
The flag 0x10 adds no block: the header stays at 3 words.
A NOTIFICATION
It carries the callId of the request that subscribed.
The callId pairs everything
Every answer, notification, chunk, credit or cancel carries its call’s callId. An odd callId comes from a call the client opened, an even one from a call the server opened. The two sides thus draw from two separate halves, and the server can call its client too. Such a call is a command.
Each side walks through its 231 callIds, skipping those in use or reserved. At
10,000 calls a second, a lap takes two and a half days. When every one is taken, the call fails
on the spot with CAPACITY_EXCEEDED.
A peer has the other answer 64 calls at once on a connection,
maximumAnsweringCalls, as HTTP/2 bounds the streams a peer opens. Nothing
announces this number: both ends set it alike. The requester keeps to it, and the receiver
refuses one more request with CAPACITY_EXCEEDED before reading anything of it
(§9.2).
An answer goes once, and only while its call is open. One that arrives after a cancel or a close goes nowhere, so that it never settles someone else’s call.
A failure travels once
A failure is the payload of a FAILURE, and nowhere else. It holds a 2-byte code, the number of relays it crossed, a byte of flags, then a readable detail in UTF-8.
The flags’ bit 0x01 says the code belongs to the procedure, so a service’s code 19
never passes for PROCEDURE_UNKNOWN. The detail holds 1,024 bytes at most by
default, maximumDetailBytes. A failure a side answers for an error of its own, a
resolver that throws for instance, carries no detail: the error stays with that side, reported
to its application.
The protocol’s codes go in blocks of 16, by the stage reached: the envelope, the route, the admission, the payload, the procedure. A peer that does not know a code knows its stage at least. Each failure has one of four fates.
- answeredsent to the peer, on the request’s callId
- reportedraised locally, never sent
- rejectedthe call fails before it goes
- gateanswered if the gate allows it, reported otherwise
| Code | Name | Stage | Fate | When |
|---|---|---|---|---|
| 1 | ENVELOPE_MALFORMED | envelope | gate | The bytes do not form an envelope this layout reads. |
| 2 | LAYOUT_UNSUPPORTED | envelope | gate | A layout to come, 0xA2 to 0xAF; the detail says the ones read. |
| 3 | KIND_UNSUPPORTED | envelope | reported | A kind to come, 0x17 to 0x7F, or a NOTIFICATION a server received. |
| 4 | ENVELOPE_TOO_LARGE | envelope | gate | A request past chunkBytes, or another envelope past maximumEnvelopeBytes. |
| 16 | DESTINATION_UNKNOWN | route | answered | A destination the relay does not have, or a peer that relays nothing. |
| 17 | DESTINATION_UNREACHABLE | route | answered | The request could not leave for its destination: nothing was tried. |
| 18 | SERVICE_UNKNOWN | route | answered | No procedure is registered for this service. |
| 19 | PROCEDURE_UNKNOWN | route | answered | The service exists, not the procedure. |
| 20 | CALL_UNKNOWN | route | reported | A RESULT, a FAILURE or a CHUNK no call awaits. |
| 21 | SUBSCRIPTION_UNKNOWN | route | reported | A NOTIFICATION under no subscription. |
| 22 | SUBSCRIPTION_UNSUPPORTED | route | answered | A subscription on a path with no notification: a relay, an HTTP connection that does not listen. |
| 32 | SESSION_REQUIRED | admission | answered | The credential does not hold, or the procedure wants a session the call lacks; also sent when a session ends. |
| 33 | ACCESS_DENIED | admission | answered | The admission refuses the call, on its header and its session. |
| 34 | PAYLOAD_TOO_LARGE | admission | rejected | The payload is past its procedure’s or its router’s limit, 16 MiB by default. |
| 35 | CAPACITY_EXCEEDED | admission | answered | Too many calls at once, no free callId left, or a full queue or budget. |
| 36 | CREDENTIAL_UNSUPPORTED | admission | answered | A credential scheme this peer does not take; the detail names its own. |
| 48 | CHUNK_INVALID | payload | answered | A chunk outside the rules: empty, misplaced, of the wrong size or past the total. |
| 49 | CREDIT_EXCEEDED | payload | answered | A chunk past the credit granted. |
| 50 | PAYLOAD_UNDECODABLE | payload | answered | The procedure’s codec refuses the payload. |
| 51 | TRANSFER_STALLED | payload | answered | Nothing came for a transfer for 30 s, by default. |
| 52 | PAYLOAD_INVALID | payload | answered | The payload reads, but breaks a rule of its message. |
| 64 | PROCEDURE_FAILED | procedure | answered | The procedure, its admission or a credential’s verifier failed. |
| 65 | DESTINATION_LOST | procedure | answered | The relay lost its destination after sending: the procedure may have run. |
In the code
The requester receives a FailureError. isProtocol tests a code of the
protocol, isApplication a code of the procedure.
import { FailureError, ProtocolCode } from '@francisdesjardins/bead/client';
import { violationsOf } from '@francisdesjardins/bead/service';
try {
await echo('')();
} catch (reason) {
if (reason instanceof FailureError && reason.isApplication(21)) {
// the procedure's own code 21: it refuses an empty text
} else if (reason instanceof FailureError && reason.isProtocol(ProtocolCode.SESSION_REQUIRED)) {
// the protocol's code 32: open a session, or renew its credential, then call again
} else if (reason instanceof FailureError && reason.isProtocol(ProtocolCode.PAYLOAD_INVALID)) {
// code 52: each violation names its field and its rule, more counts those left out
const { more, violations } = violationsOf(reason);
}
}
What does not read
An envelope that does not read has no callId to trust. Answering it at random could settle someone else’s call, so everything goes through one gate (§9.6).
A REQUEST whose prefix reads, of the peer’s parity and on a free callId, receives a FAILURE on
that callId. A RESULT or a FAILURE that does not read fails the call it aimed at, and the peer
receives a CANCEL. A CHUNK that does not read ends its transfer with
CHUNK_INVALID. Everything else is reported locally, never answered. What arrives
late on a reserved callId is dropped in silence.
A payload’s rules
A request must often hold more than its encoding says: a count of 10 at most, a name of 1 to 63
characters. Those rules are written in the .proto with protovalidate’s options,
the protobuf ecosystem’s standard, and beadc compiles them into native validators
in each language.
import "buf/validate/validate.proto";
// Where the count starts, 10 at most.
message CountDownRequest {
int32 from = 1 [(buf.validate.field).int32.lte = 10];
}
The server judges each request once its payload is read, before its resolver. A request its
rules refuse receives PAYLOAD_INVALID (52), and its resolver never sees it. A
client can also judge its value before sending, with the validates option: the
call then fails on the spot, with the same code and the same detail. The rules judge the
payload alone. A name already taken, a balance or a right stay with the resolver, which answers
a failure of its procedure.
A PAYLOAD_INVALID FAILURE
The detail carries a line a violation, then a line that closes.
{"bounds":{"lte":10},"fieldPath":"from","message":"must be less than or equal to 10","ruleId":"int32.lte"}
{"more":0}
Each line is a JSON object in RFC 8785’s canonical form: the field’s path, the rule’s id as
protovalidate names it, its bounds and its message. The last line counts the violations left
out, {"more":N}. The offending value never travels, since it may be a secret.
What beadc compiles, and how a pattern is judged
beadc compiles required and ignore, a number’s bounds
and lists of values, the lengths, pattern, prefix and suffix of a string or of bytes, an
enum’s values, a list’s and a map’s rules, and oneof. It refuses any other rule,
VALIDATE_RULE_UNSUPPORTED, and ignores none: a rule written and not played would
let through what a protovalidate server refuses.
A pattern follows RE2’s syntax and is judged in linear time, with no backtracking (CWE-1333).
beadc builds its automaton ahead, from its own Unicode tables, and each side reads
one cell a character. Violations follow an order Bead fixes, field by field. The order, the
paths and the detail’s format are in §18.
Cancel
A requester that gives up on a call tells the peer with a CANCEL, 8 bytes on the call’s callId. A CANCEL is never answered (§10).
It sends one when its timeout expires, when its signal aborts it, or when it drops a
subscription. The other end forgets the call, undoes the subscription and aborts its
resolver’s signal. The requester then reserves the callId for the call’s timeout, 5 s if it had
none, and drops what arrives late on it. It is HTTP/2’s RST_STREAM, kept to the
requester, as a gRPC client cancels. The side that answers ends a call with a FAILURE, which
carries a code, and so needs no CANCEL.
Subscriptions
A subscription is a call left open. Its request carries the SUBSCRIPTION flag, and the server then notifies under its callId (§11).
The request states its intent, as a CoAP GET registers only with its Observe option. An ordinary request to the same procedure subscribes to nothing. The client listens on the callId before it sends the request, so a notification that arrives before the RESULT is heard. The subscription ends at an unsubscribe, a cancel, the connection’s close, its session’s end, or on a failure the application chooses. A connection holds one subscription per procedure and per session.
At the client’s pace
With PACED beside SUBSCRIPTION, the server sends only the notifications the client has
granted. The client grants the next one when its callbacks are done with the previous one,
promises included. It grants with a CREDIT that carries PACED, an absolute count like RSocket’s
REQUEST_N.
Past its credit, a notification waits in a queue. The server can give it a key. A notification
of the same key replaces the one waiting, so an entity’s state keeps only its latest value:
forty-five changes to an article, while a callback fetches its data, arrive as two
notifications. Without a key, every event is kept, in order. A full queue, 64 notifications or
1 MiB by default, ends the subscription with CAPACITY_EXCEEDED. Nothing is lost in
silence: the client subscribes again and reads the state anew.
The two envelopes of a paced subscription
A REQUEST that opens a paced subscription
The request itself grants the first notification.
A CREDIT of notifications
Two notifications sent at most, the first one handled.
Sessions
A session is what a call runs in: an identifier, data the admission and the procedure read, and an end. Each call runs in the session its credential leads it to (§13).
There are two kinds. A connection’s session opens by a request, a login procedure for instance, and the server keeps it with a 128-bit token drawn at random. A credential’s session is what a verifier draws from a JWT, or from a credential the transport carries. A call without a credential runs in its connection’s session, if there is one.
The server announces the connection’s session in the CREDENTIAL block of a RESULT or a FAILURE,
and only when it changes: OPAQUE and its token when it opens, NONE when it ends. The client
presents it only on the call that asks for it. When a session ends and the connection stays,
each subscription it bounded receives SESSION_REQUIRED.
A request’s credential
A request presents a credential in its CREDENTIAL block. Its scheme names a format, as HTTP names an authentication scheme: a peer knows what the bytes are without reading them.
| Scheme | Name | Its bytes | On a request |
|---|---|---|---|
| 0 | NONE |
None | Malformed; on an answer, the session has ended |
| 1 | OPAQUE |
A session’s token, 16 bytes | Its connection’s session, if the token is that session’s |
| 2 | JWT |
A signed access token (RFC 9068) | The session its claims carry, once verified |
| 3 | ELSEWHERE |
None: the transport carries the credential | The session the application’s verifier draws from it |
| 4 and up | to come | Those of a format to come | Read, then refused CREDENTIAL_UNSUPPORTED |
Two session envelopes, byte by byte
A REQUEST that presents a JWT
The block holds its scheme, its length, then the token, padded with zeros to the word.
A FAILURE that announces the end
NONE has no bytes: the block takes 4.
The call’s session
The server judges the credential, then the admission, on the header alone. A scheme it does
not take receives CREDENTIAL_UNSUPPORTED (36), and the detail names those it
takes, as a 401 names them in WWW-Authenticate. A credential that does not hold
receives SESSION_REQUIRED (32), whatever the procedure wants, as an invalid bearer
token receives a 401. A verifier that fails gives PROCEDURE_FAILED. Last, the
admission decides on the call’s session, which the resolver then receives.
A procedure never receives its call’s bearer credential. The token goes only to the verifier and, through a relay, to the destination. It is a gateway’s model: the one that verifies a token does not hand it to the code it serves.
A JWT, verified one way
A JWT is an access token of RFC 9068’s profile, and every side verifies it the same way, as RFC
8725 recommends. Its typ is at+jwt, which rules out an identity token
from the same issuer. Its key is one of those the application gives, each with its issuer and a
single algorithm, ES256 or HS256. Its iss must be that key’s issuer, its
exp must hold with 60 s of leeway, and its aud must name the peer’s
audience. A token never chooses its own key: jwk, jku,
x5u and x5c are never read. Its session is named by its issuer and its
sid, or else its sub.
A key set, besides the keys
An application can give a key set for some issuers. Bead asks for it when no known key carries
the token’s kid, at most once a minute per issuer, so that no peer has it queried
at will. The keys received hold for ten minutes. At most 64 calls wait on one request, then
the next receives CAPACITY_EXCEEDED. Bead fetches nothing by itself: the function
that brings the keys belongs to the application (§13.9).
A credential’s session ending
A JWT’s session ends at its exp plus 60 s. That of a credential the transport
carries ends when its verifier says so. The server keeps it only while it bounds a
subscription, and a renewed token of the same identifier pushes its end back. At its end, each
subscription receives SESSION_REQUIRED. The application can also end it by its
identifier, endSession, or end all of an issuer’s whose key leaked,
endSessionsOf. It is no revocation: a token still valid opens the session again.
The client’s credential
A client presents a default credential, given in its options, or a credential of a call’s own.
A call can also present none. ELSEWHERE leaves the credential to the transport: the
Authorization field of each POST over HTTP, or that of the handshake
of a WebSocket client outside a browser.
When a call that presented the default receives SESSION_REQUIRED, the client
withdraws that default and tells the application, credentialWithdrawn. Until it
gives a new one, each call that would present it fails before it goes. No call thus falls in
silence into another session. For the same reason, an admission answers
ACCESS_DENIED, never SESSION_REQUIRED, to a call that presented a
credential.
A bearer credential leaves the machine only under TLS (RFC 6750 §5.3): wss or
https. On the machine, the loopback, a Web Worker or a router of the same process
are enough. Elsewhere, the call fails before it goes with DESTINATION_UNREACHABLE,
and the credential stays. A server behind a TLS proxy declares itself secure by the
secure option.
Transfers
A payload whose envelope would exceed one chunk, 1 MiB by default, goes as a transfer. It moves forward chunk by chunk, at the pace of the credit its receiver grants, while the other calls go on (§12).
The REQUEST or the RESULT that opens the transfer carries the TRANSFER block, the total on 8
bytes, and its first bytes, sent with no credit. The receiver answers with a CREDIT: the offset
below which the sender may send. This credit is absolute, like QUIC’s
MAX_STREAM_DATA, so a credit repeated or late never grants too much. The receiver
grants a window of 4 MiB, renewed at its half.
The sender sends CHUNKs, each with its offset on 8 bytes and MORE on all but the last. Each chunk is exactly full, but the last. The receiver places them in any order, drops what it already has, then hands the whole payload to the procedure or to the requester. A transfer of 20 MiB thus goes as an opening, nineteen full chunks and a last one of 324 bytes.
A transfer is refused before its mass goes by. Its opening passes the route, the credential, the admission and the limits before a single byte is granted. A receiver holds four transfers per connection at most, and 256 MiB for all of them together.
The requester stops a transfer with a CANCEL. The side that answers stops it with a FAILURE:
TRANSFER_STALLED after 30 s with nothing, CHUNK_INVALID or
CREDIT_EXCEEDED for a chunk outside the rules. A NOTIFICATION, a FAILURE and the
result of a call that opens a subscription are never split.
A 20 MiB transfer, byte by byte
The opening
The flag 0x04 adds the total: the header grows to 5 words.
A CREDIT
What came, 1,048,556 bytes, plus a window of 4,194,304.
A CHUNK, then the last
An offset, and no length: the transport delimits the envelope.
The limits, and what a transport owes Bead
Nothing announces the limits: both ends of a connection set them alike.
| Limit | Default | What it bounds |
|---|---|---|
maximumEnvelopeBytes |
16,778,240 | Any envelope other than a request |
chunkBytes |
1,048,576 | A chunk, and a whole request |
windowBytes |
4,194,304 | The lead a receiver grants |
maximumPayloadBytes |
16,777,216 | A payload; a procedure can tighten it |
bufferedBytesBudget |
268,435,456 | What a side reserves for the transfers it receives |
maximumTransfers |
4 | The transfers received at once on a connection |
transferInactivityTimeout |
30,000 ms | The wait for the next chunk or credit |
maximumDetailBytes |
1,024 | A failure’s detail |
maximumAnsweringCalls |
64 | The calls a connection has answered at once |
maximumUntakenEnvelopes, …Bytes |
64, 1,048,576 | What a router has not yet taken of one connection, past which its transport pauses that connection’s reads |
The transport delimits its messages, carries both directions and delivers each envelope once, in order. Knowing whether a connection lives is its job too, and Bead has no ping of its own. WebSocket does all of it (§12.2, §15.1).
Relays
A server can relay a request to a destination registered with it, as a gateway does for what sits behind it. The requester chooses the destination by 4 bytes, the DESTINATION block of its request (§14).
The relay terminates each leg. Leg A runs from the requester to the relay, leg B from the relay to the destination, and each has its callIds, its limits and its session. Only the payload’s bytes cross, undecoded, with the credential the relay forwards and the answer. Each refusal falls at the opening, and a CANCEL on leg A cancels leg B.
The relay forwards a bearer credential, and the destination verifies it. Each destination is
registered with the audience it verifies, as a resource is named for a token (RFC 8707). By
default, the relay’s admission, requireCredential, admits only a credential it can
forward. A call with no credential, or with OPAQUE, receives ACCESS_DENIED.
Otherwise all these requesters would share leg B’s session, and the relay would lend its own
authority (CWE-441). A relay that shares says so in its code: a beforeRoute that
checks the destination against the requester’s session, or shareLegBSession.
| Leg B | Leg A answers |
|---|---|
| A result, or the target’s failure | That result, or that failure counted one relay more |
| An error before its transport has the request | DESTINATION_UNREACHABLE (17): nothing was tried |
| An error after, the connection lost for instance | DESTINATION_LOST (65): the procedure may have run |
These two codes are an HTTP gateway’s 502 and 504, and what gRPC’s retry rules rest on.
What a relay does not do, and what its requesters share
A relay carries no subscription: it refuses to open one with
SUBSCRIPTION_UNSUPPORTED. It forms no chain, since leg B names no destination.
Its failures name no address behind it.
All of a relay’s requesters share its leg B: one space of callIds, the same 64 calls at once,
the same capacity for transfers. So the relay’s admission, which sees the procedure and the
destination, checks the rights. By default, it does not verify a JWT it forwards, so a forged
token learns which destinations exist before the destination refuses it. With the
verifies option, the relay verifies the token itself, from its first step.
A destination a connection carries dies with it. It stays registered, unreachable, until the
application removes it, or registers the newcomer in its place, replaces.
Over HTTP
Over WebSocket, each envelope is a binary message, in both directions. HTTP serves where
WebSocket cannot pass. It keeps no connection, so the transport draws one, and each
POST carries an envelope (§15.4 to §15.12).
WebSocket does all that Bead asks of a transport: it delimits messages, carries both directions
and delivers in order. Each end sends a PING every 30 s, and closes a connection left without
an answer: a peer gone without a word ends in a close, instead of staying open forever. A
message past maximumMessageBytes closes the connection with code 1009 (RFC 6455).
A POST with no body connects. The server answers 201 with a 16-byte token in the
bead-connection-token field, which each later exchange presents, and a
DELETE ends the connection. The router, the sessions and the relays see a
connection there as over a socket. Each envelope has the type
application/prs.bead. The transport reads only its layout, its kind and its
callId, at the prefix’s fixed offsets, to hand each answer to its request’s exchange.
The server marks each of its refusals in the bead-refusal field, and only a marked
refusal fails the send beyond doubt: nothing was tried. Any other status, a gateway’s 502 for
instance, may have come after the request ran, and counts as an exchange lost. A relay thus
always tells DESTINATION_UNREACHABLE from DESTINATION_LOST. A client
follows no redirect: it would carry the connection’s token where no one judged it.
A call presents ELSEWHERE in the Authorization field of the POST that
carries it. Two calls of one connection can thus present two credentials, and a token is
renewed without reconnecting. The server takes only the hosts and the origins it knows:
security explains it.
What the server sends of its own
A notification, a command or a relayed request has no exchange to go to. They wait, numbered,
in the connection’s outbox. The client listens with a GET /bead the server holds
until something arrives, 30 s at most. The server then reports a batch, each envelope preceded
by its length on 4 bytes. The next listen acknowledges what was heard by its
bead-after field, and the rest comes again.
A listen lost resumes where it was, after 1, 2, 4 s and up to 30, each wait drawn at random. A brief cut costs nothing: the same connection, its session, its subscriptions, and all that arrived meanwhile, in order. It is a long poll (RFC 6202): no heartbeat, and nothing an intermediary buffers.
A client that does not listen receives no subscription, no command and no relayed request. HTTP carries no transfer: a request’s body arrives before its response goes, and no credit could come back. Under TLS, the server serves HTTP/1.1 and HTTP/2 on one port.
A request’s timeouts, and WebSocket’s handshake
A request’s head must come whole within 30 s, headersTimeout, or the server
answers 408. A body must come at 240 bytes a second at least, past a grace of 5 s, or it
receives 408 too-slow. Each field must be visible ASCII, or the request receives
400 before anything else (§15.12).
WebSocket’s handshake is an HTTP request. The WebSocket server judges it by its own
hosts and origins, under the same rules, and bounds its connections
as an HTTP server does: 10,000 in all, 64 per address. A handshake without
Origin, a client’s outside a browser, carries its Authorization
beside each envelope of its connection. A page’s carries none, since a browser adds to it what
it keeps on its own. A page therefore presents a JWT.
In memory, and several transports
The in-memory transport joins a client’s router and a server’s within one process. The joined transport gives a server its connections from several transports at once (§15.13, §15.14).
In memory, each envelope is copied as it leaves, then delivered whole, once, in order, at a later
turn. The transport carries a credential beside a request, as Authorization does
over HTTP. It never leaves the process, so both its ends are secure. Each side plays its
conformance conversations through a pair of this transport.
The joined transport, joinTransports on the TypeScript side and
JoinedServerTransport on the C# side, gives one router, one admission, one handling
of sessions, and each service offered once, whatever the call’s transport. Each connection
stays its transport’s. That is how the HTTP/JSON gateway calls its
application’s server.
const webSocket = createWebSocketTransportServer({ WebSocketServer });
const memory = createMemoryTransportServer();
const server = createServer({ transport: joinTransports([webSocket, memory]) });
offerHello(server);
// what beadc generates: the route table, the document, the problem types' base
const gateway = createHTTPGateway({
createServer: (listener) => {
return createNodeServer(listener);
},
document: OPENAPI_DOCUMENT,
problemTypes: HTTP_PROBLEM_TYPES,
routes: HTTP_ROUTES,
transport: memory,
});
await memory.start();
await webSocket.start({ port: 8080 });
await gateway.start({ port: 8081 }); // localhost: both loopbacks
Over a broker
A topic-based publish/subscribe broker is one more transport, and Bead never becomes a network of its own there. Each side holds its core, an in-memory broker for tests, and two bindings: MQTT 5, and AMQP 1.0 on RabbitMQ 4 (§15.16 to §15.38).
A participant is a side’s link to its broker. Its identifier is the identity the broker
authenticates, and its inbox, the topic bead/i/<id>, receives all that comes
to it. A server serves a name, bead/s/<name>, alone or in a group of
participants that share it.
A broker often joins services and devices already. Bead adds what topics alone lack: calls with their answers and their failures, cancel, each client’s pace and transfers under credit. Both ends keep the same API as over WebSocket.
A connection is placed once, at its opening, and named by its two ends, as a TCP connection is by two sockets. The client publishes an OPEN to the server’s name. The broker delivers it to a single member of the group, which answers an ACCEPT to the client’s inbox. The client confirms with a PING, the third leg, like that of TCP’s handshake. From then on, everything goes from inbox to inbox.
Each envelope travels in a parcel, with a 28-byte header of layout 0xB1. Each end
numbers what it sends. A copy the broker delivered twice is dropped before the router, and a
loss brings the connection down. An envelope too large for the peer’s inbox goes in parts. Bead
keeps its own rules on top: a subscription’s credit, a request’s credential, sent to the server
alone, and a heartbeat on a silent connection.
The two bindings, MQTT 5 and AMQP 1.0
Over MQTT 5
Each participant is a clean link, which the broker does not keep past its connection: Clean
Start and a session that expires at 0. Each parcel is a PUBLISH at QoS 1. A name a group serves
is a shared subscription, $share/<name>/bead/s/<name>. Each packet
is counted to the byte under each end’s maximum size. A participant’s will announces its
departure to the peers. The TypeScript side goes through MQTT.js, the C# side through
MQTTnet, and both are played against Mosquitto.
Over AMQP 1.0
The binding targets RabbitMQ 4. Each link is a fresh connection, and its inbox an exclusive queue that ends with it. The deployment provisions each name’s queue, and the broker delivers each of its messages to a single member. Each message follows a single profile, counted to the byte. AMQP 1.0 has no will: Bead’s heartbeat alone says a peer went quiet. The TypeScript side goes through rhea, the C# side through AMQPNetLite, and both are played against RabbitMQ 4. No page reaches an AMQP 1.0 broker.
In both cases, a deployment’s broker authenticates each link, by a password or a TLS client certificate, and its access control lets a participant alone read its inbox. Each binding’s interop runs in CI, both ways.
In HTTP and JSON
Beside the binary wire, a gateway answers a service’s procedures in HTTP and JSON. A page,
curl or a client generated from an OpenAPI document call them knowing nothing of
Bead.
The gateway is one more client of its application’s server, through the in-memory transport. Each HTTP exchange draws its own Bead connection, closed when its response ends. What Bead ties to a connection, its session or its subscriptions, thus stays with a single caller: a shared connection would lend the next caller the first one’s session (CWE-441). Its calls go through the server’s router, admission, sessions and rules, like any client’s.
A companion of the specification describes it, spec/transcoding.md, under its own
version, 0.2.2. The gateway listens on its own port,
localhost by default, or mounts into the application’s web server.
A call’s credential is its request’s authorization field. By default, the gateway
presents it ELSEWHERE, and the server’s verifier makes of it what the application decides. With
the bearer-jwt option, it presents a Bearer field’s token as a JWT,
which the server verifies. It reads no credential from a cookie or a query string, and offers no
command.
A call to examples/hello, with curl:
$ curl -H 'content-type: application/json' -d '{"name":"Ada"}' http://localhost:8081/api/Hello/sayHello
{"greeting":"Hello, Ada!"}
The routes
Each procedure a client calls answers at
POST /api/<Service>/<procedure>. It also answers GET when
the .proto marks it idempotency_level = NO_SIDE_EFFECTS, the Connect
protocol’s rule. Besides, each google.api.http rule of its method, the standard
grpc-gateway and Envoy read, adds a REST route beside the default ones. An unknown path receives
404, a method refused 405.
The matching, the binding and the OpenAPI document
The path is read raw, with no normalization. When two routes match, a literal goes before
*, and * before **. A request binds from its variables,
its query string and its body, and a field set twice is refused. A PATCH with no
update_mask gets one from its body’s keys, as AIP-134 has it.
beadc writes each side’s route table and one OpenAPI 3.1.1 document per
blueprint, the same bytes whatever writes it. The gateway serves it at
/openapi.json, with an etag. A payload’s rules are written there in
JSON Schema 2020-12’s words. The detail is in the companion’s §3, §4 and §11.
A message’s JSON
A proto payload is written in protobuf’s JSON mapping. A json payload
passes as it is, a text one as text/plain, a raw one as
application/octet-stream. The reference libraries do not agree on that mapping, so
Bead has its own reader and writer in each language. It writes one form: no white space, the
members in the order of their field numbers, a map’s entries sorted. In CI, protobuf’s
conformance suite, in binary and in JSON, plays that mapping on each side.
A failure, a problem
Each failure answers an RFC 9457 problem, application/problem+json.
code and scope carry what the wire carries, and detail
the failure’s detail. The status depends on where the code arose: what the caller can fix
gives a 4xx, what happens between the gateway and its server a 5xx.
| Status | What raised it |
|---|---|
| 400 | PAYLOAD_UNDECODABLE, a reading refused; an application’s failure |
| 401 | SESSION_REQUIRED, its challenge in www-authenticate |
| 403 | ACCESS_DENIED |
| 413 | ENVELOPE_TOO_LARGE, PAYLOAD_TOO_LARGE |
| 422 | PAYLOAD_INVALID, its violations in errors |
| 429 | CAPACITY_EXCEEDED, retry-after: 1 |
| 500 | PROCEDURE_FAILED, and what is said only between the gateway and its server |
| 501 | SERVICE_UNKNOWN, PROCEDURE_UNKNOWN |
| 502 | an answer the gateway could not take; DESTINATION_LOST |
| 503 | DESTINATION_UNREACHABLE, retry-after: 1 |
| 504 | TRANSFER_STALLED, or the call’s timeout, 5 s by default |
A PAYLOAD_INVALID gives a 422 whose errors place each violation:
pointer into the body, or parameter for a variable of the path or of
the query string. Here, from is past 10 in the body, and title, a
path variable, is past 3 characters.
POST /v1/ruled/Bead HTTP/1.1
content-type: application/json
{"from":12}
{
"code": 52,
"errors": [
{
"bounds": {"lte": 10},
"detail": "must be less than or equal to 10",
"fieldPath": "from",
"pointer": "#/from",
"ruleId": "int32.lte"
},
{
"bounds": {"max_len": "3"},
"detail": "must be at most 3 characters",
"fieldPath": "title",
"parameter": "title",
"ruleId": "string.max_len"
}
],
"scope": "protocol",
"status": 422,
"title": "PAYLOAD_INVALID",
"type": "tag:francisdesjardins.ca,2026:bead/PAYLOAD_INVALID"
}
Notifications, as Server-Sent Events
A request that asks for text/event-stream in its accept opens a
subscription. The result goes first, event: result. Each notification follows with
no event name, so an EventSource hears it on onmessage. The failure
that ends the subscription goes as event: failure. The gateway paces the
subscription at the rate the reader takes the events, and a late reader holds the server’s
queue, never the gateway’s memory.
event: result
id: 1
data: {"ticks":2}
id: 2
data: {"remaining":1}
id: 3
data: {}
event: failure
id: 4
data: {"code":2,"scope":"application","status":400,"title":"COUNTED","type":"https://example.com/problems/COUNTED"}
Its gates and its bounds
The gateway judges a request by the HTTP transport’s gates, in the same order, then adds Fetch
Metadata: a request with no origin from another site is refused 403, but a
navigation to the document or to a GET route. A call waits 5 s for its answer by
default, then receives 504, and no field of the request changes that timeout. A request
abandoned closes its connection, and the server cancels its calls.
What the wire can do and a gateway does not carry is a row of the support matrix’s “Leveled down”: a command, a connection’s session, a stream’s resumption (the companion’s §10).
The client beadc writes
With http: true, beadc also writes a typed client of the default
routes, over fetch on the TypeScript side and over HttpClient on the C#
side. It reads each problem as a Bead failure, and a stream as an async sequence. The interop
calls each side’s gateway with the other side’s client.
Security, plainly
A Bead server keeps itself behind five gates, from the outermost to the innermost. Each gate answers one question, and each refusal says why.
The listening address says who can reach the server. The hosts and the origins say which browser page it listens to. The credential says on whose behalf the call is made. The procedure’s admission says what that caller may do. The payload’s rules say what the request may carry.
Encryption is no gate of Bead’s. It is TLS’s job, under the transport, set up by the deployment. Of its own, Bead only refuses to send a bearer credential in clear off the machine.
| The gate | What it stops | Its refusal |
|---|---|---|
| The listening address | Another machine, while the server listens on the loopback | None: the system refuses the connection |
hosts |
A page that pointed its own name at the server | 403 host-refused |
origins |
A page of an origin the server does not name | 403 origin-refused |
| The credential | A call that proves nothing when it must | SESSION_REQUIRED (32), like a 401 |
| The admission | A known caller, without the right to call this procedure | ACCESS_DENIED (33), like a 403 |
| The payload’s rules | A value the .proto’s rules refuse |
PAYLOAD_INVALID (52) |
The first three gates are the HTTP and WebSocket transports’, and their refusals are HTTP statuses. The next ones are the router’s, on any transport, and their refusals are FAILUREs on the call’s callId.
A credential, a session
Over HTTP, one says who one is with a session cookie or an Authorization: Bearer
token. Bead has both, and a third way.
-
OPAQUE: the token the server drew when a procedure opened the session. It is Bead’s session cookie, tied to its connection. -
JWT: a signed access token, which each server verifies on the spot, with the keys its application gives it. -
ELSEWHERE: the credential travels in the transport, and the application’s verifier draws the session from it.
Sessions says how each is verified and how it ends.
Origins and hosts
The threat here is a web page. A page you open can send requests to a server on your machine, and it is your browser that sends them. A WebSocket has no same-origin policy: a page of any site can open one.
origins stops a page of another origin. The browser writes the page’s origin in
Origin, and the page cannot change it. The server takes a request with no
Origin, of its own origin or of an origin it names, and refuses any other.
hosts stops a page that points its own name at the server, DNS
rebinding. Its request then carries its name in Host. The server takes only
localhost, the names under .localhost, IP addresses, and the hosts it
is told. A server reached by a public name therefore names it.
These two gates keep the server from pages. A program writes the Host and the
Origin it wants, and against it stand the listening address and the credential.
What to remember
-
EncryptionIt is TLS:
wss,https, or a proxy in front of the server. -
AuthorizationIt falls to each procedure’s admission, which the application writes.
beadc checkrefuses a service that declares none. - RateBead bounds what a peer holds of a server, and counts nothing per second. Rate limiting falls to the deployment.
-
A stolen tokenA JWT is good for whoever holds it, until its
expplus 60 s. Bead ties it neither to a connection nor to a client’s key. - A payload’s meaningBead bounds its size and judges it by its rules. A balance or a right stays with the procedure.
- Secrets in the logsNothing a side reports carries a credential’s bytes, only its scheme.
How a call presents its credential
Each call carries its own, so two calls of one connection can speak for two people. A bearer credential never leaves the machine in clear. The client’s credential details the default, its withdrawal and the TLS rule.
A relay
A relay terminates each leg and forwards a JWT as it is to the destination of its audience, which verifies it. By default it refuses a call with no credential, so as not to lend its own session to any requester. Relays details it all.
CORS, over HTTP
The HTTP server answers the Fetch Standard’s CORS protocol to the origins
origins names, and to no other. It never allows a cookie: no response carries
Access-Control-Allow-Credentials, since Bead’s credential is never a cookie. No
browser asks CORS for a WebSocket handshake, and origins alone judges it. A
gateway in front of the server that adds CORS fields of its own breaks each page listed.
The address it listens on
A server listens on localhost by default, on both loopbacks,
127.0.0.1 then ::1, and the network does not reach it (CWE-1327). A
container, or a server another machine reaches, listens on 0.0.0.0 or
::, and names its hosts. The address keeps the network out, and
hosts the pages of the machine itself.
What TLS does, and Bead does not
Bead has no encryption of its own. Its envelopes and its tokens pass as they are in the transport. Over HTTP in clear, whoever reads the traffic also reads the connection’s token and speaks in its name. Off the machine, HTTP and WebSocket are therefore served under TLS, and the certificates are the deployment’s concern.
The refusal event
A page whose WebSocket is refused sees only a close, 1006, with no reason. Each server
therefore tells its application each refusal it judges: the status, the reason, the request’s
authority and origin, the client’s address and port. Never the target, nor a token. Behind a
proxy, it believes X-Forwarded-For only from the proxies
trustedProxies names.
transport.on('refusal', ({ address, authority, origin, port, reason, status }) => {
// 403 host-refused: authority "rebind.example:8080", origin "http://rebind.example:8080", from 127.0.0.1:53114
console.warn(`refused ${status} ${reason}: authority ${JSON.stringify(authority)}, origin ${JSON.stringify(origin)}, from ${address}:${port}`);
});
The accessRefused event
Each server also tells each call its router refuses at the credential or at the admission: the
stage, the code, the reason in a word, the scheme, what was called, the connection and the
address. Never the credential nor its claims, as OWASP’s ASVS 5.0 logs an authentication
failure without its secrets. A JWT refused for exp or kid thus tells
the operator whether a clock drifted or a key rotated (§13.12).
server.router.on('accessRefused', ({ address, code, credentialScheme, port, procedureId, reason, serviceId, stage }) => {
// credential 32 exp: an expired JWT, scheme 2, service 1, procedure 4, from 203.0.113.7:53114
console.warn(`refused ${stage} ${code} ${reason ?? ''}: scheme ${credentialScheme}, ${serviceId}/${procedureId}, from ${address}:${port}`);
});
What a peer holds of a server
A peer that holds on to a server costs it memory, sockets and time. Each side therefore bounds what a peer holds of it, on the model of HTTP/2 and Kestrel, and each bound can be set.
| Bound | Default | What it holds |
|---|---|---|
maximumAnsweringCalls |
64 | The calls at once on a connection |
maximumConnections, …PerAddress |
10,000, 64 | A server’s connections, in all and per address |
minimumSendBytesPerSecond |
240 | The pace at which a client takes what waits for it |
maximumUnsentBytes |
184,551,424 | What waits for a WebSocket client’s socket |
headersTimeout |
30 s | The time a request’s head has to come |
minimumBodyBytesPerSecond |
240 | A body’s pace, over HTTP |
maximumMessageBytes |
16,778,240 | A WebSocket message; past it, 1009 |
A client that no longer reads is dropped, and the server reports each drop, naming its bound (§15.12).
Debugging
Two listeners tell what goes by: one each step of a call, the other each envelope. While no one listens, they cost nothing.
The service manager’s debug event tells each step of a call: its direction,
tx going out and rx coming back, its type, its procedure, its callId,
what went and came back, decoded, and its duration. Each router’s debug tells each
envelope, its header copied without its credential.
With no listener, a call reads no clock and allocates nothing for the trace: one check per call,
one per envelope. A listener that throws never changes a call. Its error goes to the
error event, and the call keeps the same outcome and the same bytes on the wire.
A server also says what it holds. gauges() counts, layer by layer, the calls, the
transfers, the subscriptions and the sessions. health() draws a verdict from them
in the IETF health-check draft’s format: a layer that keeps something for a connection gone is
a leak, and fails. Each end-to-end test ends on a server back to zero.
Debugging stays beside the wire, in a companion, spec/debugging.md
0.6.0. A constrained side can leave it out of its build and still conform to
the wire.
The DevTools extension
A Bead panel in Chrome’s DevTools shows the calls, the envelopes and the metrics of each client
a page builds. It also shows a server in a Web Worker of the page, whose messages the Network
panel does not see. Its Broker view follows a broker’s participants and their parcels. A client
announces itself to it on its own, unless its options say devtools: false.
What a listener receives
Captured on a WebSocket client: an echo request that succeeds. The listener
receives (error, info), and error stays null while the
call does not fail.
// a call's listener: (error, info), for the request echo('hi') that succeeds
null, {
direction: 'rx',
duration: 1,
options: { timeout: 2000 },
procedureId: 2,
procedureName: 'echo',
rx: 'HI',
serviceId: 1,
serviceRevision: 1,
startedAt: 1790700108977,
tx: 'hi',
type: 'request',
}
// its outgoing step, at the same start: direction 'tx', duration: undefined, rx: undefined
A failure, a notification, and a router’s envelopes
A failure arrives as error, a FailureError that says who raised it
and where. A notification has no outgoing step, no options and no duration.
// echo('fail'): the server answers the procedure's failure 21
FailureError {
code: 21,
detail: 'nothing to echo',
header: { callId: 5, kind: 0x12 },
origin: 'peer',
procedureAddress: { procedureId: 2, serviceId: 1 },
relays: 0,
scope: 1, // APPLICATION
side: 'server',
}, {
direction: 'rx',
duration: 1,
options: { timeout: 5000 },
procedureName: 'echo',
rx: { code: 21, detail: 'nothing to echo', scope: 1 },
startedAt: 1790700108978,
tx: 'fail',
type: 'request',
…
}
// a notification: no outgoing step, no options, no duration; its start, when it arrives
null, {
direction: 'rx',
duration: undefined,
options: undefined,
procedureName: 'news',
rx: 'flash',
startedAt: 1790700108981,
tx: undefined,
type: 'notification',
…
}
A router’s listener receives each envelope’s header and its connection. Here, the server announces the session on its first RESULT, then the client drops a subscription.
// the client's router, in order: its connection is always 'server'
{ connectionHandle: 'server', direction: 'tx', header: { callId: 1, kind: 0x10, procedureId: 1, serviceId: 1 } }
{ connectionHandle: 'server', direction: 'rx', header: { callId: 1, credentialScheme: 1, kind: 0x11 } } // the session announced, OPAQUE
{ connectionHandle: 'server', direction: 'tx', header: { callId: 7, kind: 0x10, opensSubscription: true, procedureId: 4, serviceId: 1 } }
{ connectionHandle: 'server', direction: 'rx', header: { callId: 7, kind: 0x13 } } // the notification
{ connectionHandle: 'server', direction: 'tx', header: { callId: 7, kind: 0x16 } } // the subscription dropped
// the server's router names the client's connection
{ connectionHandle: 'type=WS;ip=127.0.0.1;port=57218;ordinal=1', direction: 'rx', header: { callId: 1, … } }
Filtering
filterDebug and filterEnvelopeDebug let through only what a filter
keeps. The filter takes the words of the debug package’s DEBUG
variable: * for any run of characters, - to leave a name out. It adds
three selectors.
| Word | Keeps |
|---|---|
d(rx), d(tx) |
The events in that direction |
t(r), t(n), t(c) |
The requests, the notifications or the commands |
e(failure), e(chunk)… |
The envelopes of that kind |
get*, -getStatus |
The procedures whose name matches, or all but them |
const filter = () => {
return process.env['DEBUG_CALLS'];
};
services.on('debug', filterDebug((error, info) => {
console.log(info.direction, info.procedureName, error);
}, filter));
router.on('debug', filterEnvelopeDebug(({ direction, header }) => {
console.log(direction, header);
}, filter));
// DEBUG_CALLS="d(rx), t(r|c), e(failure), get*, -getStatus"
Metrics
A metrics registry, createMetricsRegistry(), is given to a router. The router
counts at its gates, by service and by procedure, in OpenTelemetry’s RPC vocabulary: the calls
that start and those that end, by outcome, on the client’s side and on the server’s. Durations
go into gRPC A66’s buckets, from 0 to 100 s. exposition() writes the registry in
Prometheus’s text format. The application serves it at /metrics: Bead opens no
port.
const metricsRegistry = createMetricsRegistry();
const server = createServer({ transport }, { metricsRegistry });
// at /metrics, for Prometheus, and a Grafana behind it
const { contentType, text } = metricsRegistry.exposition();
// contentType: 'text/plain; version=0.0.4; charset=utf-8'
An excerpt of an exposition, and what the filter and the metrics guarantee
Nine calls to echo, answered from 0 µs to 100 s plus 1 µs, as
spec/metrics.json pins them byte for byte.
# HELP bead_server_call_duration_seconds How long each call this side answered took, from its arrival to its end, in seconds.
# TYPE bead_server_call_duration_seconds histogram
bead_server_call_duration_seconds_bucket{service="1",procedure="echo",type="request",le="0.0"} 1
bead_server_call_duration_seconds_bucket{service="1",procedure="echo",type="request",le="1e-05"} 3
bead_server_call_duration_seconds_bucket{service="1",procedure="echo",type="request",le="5e-05"} 4
…
bead_server_call_duration_seconds_bucket{service="1",procedure="echo",type="request",le="0.8"} 4
bead_server_call_duration_seconds_bucket{service="1",procedure="echo",type="request",le="1.0"} 6
bead_server_call_duration_seconds_bucket{service="1",procedure="echo",type="request",le="2.0"} 7
…
bead_server_call_duration_seconds_bucket{service="1",procedure="echo",type="request",le="100.0"} 8
bead_server_call_duration_seconds_bucket{service="1",procedure="echo",type="request",le="+Inf"} 9
bead_server_call_duration_seconds_sum{service="1",procedure="echo",type="request"} 203.000023
bead_server_call_duration_seconds_count{service="1",procedure="echo",type="request"} 9
# HELP bead_server_call_handled_total Each call this side answered, as it ended, by its outcome.
# TYPE bead_server_call_handled_total counter
bead_server_call_handled_total{service="1",procedure="echo",type="request",outcome="result"} 9
…
bead_server_call_handled_total{service="1",procedure="echo",type="request",outcome="failure",code="SESSION_REQUIRED"} 0
…
# HELP bead_server_call_started_total Each call this side answered, as its request opened it.
# TYPE bead_server_call_started_total counter
bead_server_call_started_total{service="1",procedure="echo",type="request"} 9
- A filter never fails the call it traces. A direction, a type or a kind that does not exist is refused, never forgotten in silence.
- Each series exists at 0 as soon as its procedure is declared, so an alert reads it from its start.
- No label is a connection, a session, a callId or a credential: a peer does not grow the registry.
- What is under way never reads below 0, and a
_sumis written exactly.
Evolving without breaking
Every byte the layout 0xA1 does not define already has a fate. A peer that does
not know a newer version refuses what it cannot read, instead of reading it wrong.
-
A layout to come
0xA2to0xAF: refusedLAYOUT_UNSUPPORTED, the detail saying the layouts read. -
A kind to come
0x17to0x7F: reportedKIND_UNSUPPORTED. -
An advisory kind
0x80to0xFF: ignored, with no error. -
A credential scheme to come4 to 65,535: read whole, then refused
CREDENTIAL_UNSUPPORTED. - The limitsNothing announces them. What exceeds the other’s limit is refused, never truncated.
- Any other bitA flag its kind does not take, or a reserved bit, makes the envelope malformed.
Three implementations
Bead is a protocol: a specification, conformance files every implementation plays, and an interop service. The repository holds three implementations. TypeScript and C# are at behavioural parity. Rust has its core, and each part it lacks is a row of the support matrix, ahead.
| Part | TypeScript | C#, on .NET 10 | Rust |
|---|---|---|---|
| The wire | The envelope, the headers, the failures | The same, byte for byte | The same, byte for byte |
| Above the wire | The routers, the services, sessions and JWTs, a payload’s rules, subscriptions, transfers, relays, debugging and metrics | The routers, sessions and JWTs, debugging and metrics, the server half of the service layer | |
| The transports | WebSocket, HTTP, Web Worker, in memory, a broker over MQTT 5 or AMQP 1.0, and joined | WebSocket, HTTP on Kestrel, in memory, a broker over MQTT 5 or AMQP 1.0, and joined | In memory, the broker’s core over an in-memory broker, and joined |
| The HTTP/JSON gateway | On node:http, or mounted into a server |
On Kestrel, or mounted by its HttpContext |
Ahead |
On the Rust side, next come the client half of the service layer, the WebSocket and HTTP
transports with the interop, the bindings to a broker, the gateway and beadc’s Rust
target, each by a decision of its own. The core also compiles for
wasm32-unknown-unknown.
Parity holds down to the field. When one platform cannot do what another can, every side keeps
to what each can. A 64-bit field, a chunk’s offset for instance, thus stops everywhere at
253 − 1, which a JavaScript number holds exactly. The support matrix,
docs/support.md, holds each
difference with the test that shows it, and its “Leveled down” register each limit one platform
imposes on all.
The sides also measure themselves, on the loopback. TypeScript and C# measure the same layers
in the same units: the codec, an echo over HTTP and over WebSocket, paced
notifications, a 20 MiB transfer. Rust measures its in-memory calls. Each release keeps each
side’s report and compares it with the previous one’s.
Generated from a .proto
A service’s code is not written by hand in each language. beadc, the generator,
reads a .proto compiled by buf and draws a blueprint from it, a JSON file that
describes the API without depending on any language. beadc check judges it by its
rules, each refusal at its line and column. Each target then writes a server where only the
handlers are written, and the client that answers it. beadc breaking compares the
blueprint with the last release, and says what breaks a peer already deployed or the code
written against the API. beadc runs in a Docker image, and the example
examples/hello plays in CI in both languages, each client against each server.
What each implementation plays
Each unit of the specification is cited by a conformance file, or carries the reason none cites it. The TypeScript and C# sides call each other in CI, over WebSocket, HTTP, MQTT 5 and AMQP 1.0, and through their gateways.
The conformance files, in figures
- The units: 407 for the specification, 184 for debugging and 193 for transcoding, each held by the sha256 fingerprint of its text.
- The vectors: 460, in 16 groups, each header’s bytes and what a reader refuses, a JWT’s verdicts and a broker’s parcels.
- The conversations: 178 exchanges between clients and the interop server, relays included.
- The transports’ cases: 255 exchanges over HTTP, WebSocket and a broker, written byte by byte.
- The durations: 35 places that take a duration, and what each makes of 19 inputs.
- The payloads: 159 payloads read and
54 values written, in
raw,text,jsonandproto. - A payload’s rules: 180 verdicts and 64 details, and a differential against protovalidate-es in CI.
- Transcoding: 26 templates, 192 readings, 78 writings, 66 bindings, 46 failures, 10 event streams and 78 exchanges.
- Fuzzing: each suite fuzzes its readers on mutations of the vectors, and
tools/fuzz.mjsfuzzes each side’s parsers, guided by coverage.
How Bead is made
Bead is a personal project, kept at its author’s pace. Its method fits in four habits, each one a file a reader can open in the repository.
The specification first
A behaviour is written in spec/spec.md
before it is coded, in the RFC 2119 sense, then played by the conformance files every side
reads from spec/, keeping no copy. Where no file can play it, each side that carries
it has a twin test, named by one sentence in every language’s case: the TypeScript side’s
says at connect that it listens, then listens: a batch heard in order, acknowledged by the next listen
is the C# side’s
Says_at_connect_that_it_listens_then_listens_a_batch_heard_in_order_acknowledged_by_the_next_listen. Every difference between the sides
is a row of docs/support.md, with the
test that shows it.
Time passed in, never taken
No side reads the time of its own. The TypeScript side takes a clock, the part of the globals
it calls, so a test gives it a clock it advances by hand, and a timeout fires when the test
says so; spec/clocks.json holds what every side’s test clock does:
export interface IClock {
readonly Date: { now(): number };
readonly performance: { now(): number };
clearInterval(timer: TimeoutId | undefined): void;
clearTimeout(timer: TimeoutId | undefined): void;
setInterval(callback: () => void, delay: number): TimeoutId;
setTimeout(callback: () => void, delay: number): TimeoutId;
}implementations/typescript/src/transport/shared/clock.ts
On the Rust side, spawning and time go through one module, crate::platform, and
the clock alone. Clippy’s disallowed-methods refuses every other path, so the core
also compiles for a browser’s WebAssembly, wasm32-unknown-unknown, which CI
checks:
# The core spawns, yields and keeps time through one private module, `platform`, and the clock
# (decisions 0044 and 0011), so that it compiles for a browser's WebAssembly as natively; what a
# test or a native target takes from its platform goes through the test kit, or is exempted once,
# with its reason, at its root
disallowed-methods = [
# a peer's lengths and offsets use checked forms; a checked zero-copy slice states its invariant
{ path = "bytes::Bytes::slice", reason = "check the slice bounds and expect this lint at the invariant" },
{ path = "bytes::BytesMut::split_to", reason = "check the split offset and expect this lint at the invariant" },
{ path = "bytes::BytesMut::split_off", reason = "check the split offset and expect this lint at the invariant" },
{ path = "std::time::Duration::from_secs_f64", reason = "use Duration::try_from_secs_f64" },
{ path = "std::time::Duration::from_secs_f32", reason = "use Duration::try_from_secs_f32" },
{ path = "str::repeat", reason = "bound and reserve the output fallibly" },
{ path = "tokio::runtime::Handle::spawn", reason = "spawn through crate::platform" },
…
{ path = "tokio::task::spawn", reason = "spawn through crate::platform" },
…
{ path = "tokio::time::sleep", reason = "time through the clock" },
…
{ path = "std::time::SystemTime::now", reason = "time through the clock" },implementations/rust/clippy.tomlEvery rule a gate
A rule that matters is a CI gate, not a convention: the conformance files regenerated and
diffed, each side’s public surface pinned to a list, every public name mapped across the
languages, every link and every version checked, every action pinned by its commit, every
lockfile against the known advisories. Each gate is a job of
.github/workflows/ci.yml or
a script of tools/, and
a decision record
lists each rule with the job that holds it. This page has its own: its build must give the
committed page byte for byte.
AI agents, in scripted waves
Beyond the TypeScript side, much of the code is written by AI agents working under the
repository’s rules — the other implementations, the generator, many of the spec’s readings of
standards — in waves the scripts of .claude/workflows/
describe. A change that reaches across the languages first meets a design round in which one
agent is bound to disagree, the Tenth Man. Each of its objections is answered, accepted or
refused with its reason, in the decision record, under
docs/decisions/. Then each language is
implemented and integrated, and every gate runs. I decide the real dilemmas and make every
commit. The ideas, the protocol’s design and the TypeScript side are mine; where a side departs
from TypeScript’s idioms or the spec reads a standard, tests and those reviews checked it, not a
specialist, and corrections are welcome.
What is ahead
-
Rust in fullThe client of its service layer, its WebSocket and HTTP transports with the interop, its
MQTT 5 and AMQP 1.0 bindings, its gateway and its target in
beadc. -
Rules in CELThe rules across several fields, a subset
beadccompiles, then a string’s well-known formats. -
The close codeWhat the peer said as it closed a connection, carried on
close, on every transport. - A relay that carries a subscriptionWhat a page needs across a broker.
- The core in a pageThe C# and Rust sides in a page and in a worker, with the DevTools extension’s hook.
- More conformanceThe client part over HTTP and WebSocket, a relay across languages, and one corpus read alike by every side’s parsers.
-
A connection’s ageA graceful drain of long-lived connections, as HTTP/2’s
GOAWAYdrains one. - DevTools on FirefoxThe extension on a second browser.
- A counted call’s costMeasured against a call that is not counted, and a guard against a regression of the benches.
- A constrained C sideDebugging left out of its build by its switches.
-
PublicationThe packages on npm, NuGet and crates.io, and
beadc’s image.
In the code
Bead lives in the router, the middle layer. The transport below moves bytes without reading them, and the services above call and answer without seeing a byte.
The wire’s code
Ten files of the TypeScript side write and read Bead, with no dependency. The prefix reads at fixed offsets, before anything else of the envelope:
export const readPrefix = (
bytes: Uint8Array,
failures: Pick<IRouterFailures, 'envelopeMalformed'>
): IEnvelopePrefix => {
if (bytes.length < PREFIX_BYTES) {
throw new FailureError({
...failures.envelopeMalformed,
detail: `an envelope is at least ${String(PREFIX_BYTES)} bytes, not ${String(bytes.length)}`,
});
}
const view = viewOf(bytes);
const layout = view.getUint8(0);
if (layout < LAYOUTS.first || layout > LAYOUTS.last) {
throw new FailureError({
...failures.envelopeMalformed,
detail: `${hexadecimalOf(layout)} is not an envelope's first byte`,
});
}
return { callId: view.getUint32(4), kind: view.getUint8(1), layout };
};implementations/typescript/src/router/shared/header.tsAnd a header is written field by field, each at its offset:
export const writeHeader = (header: EnvelopeHeader, bytes: Uint8Array): number => {
…
const view = viewOf(bytes);
let offset = PREFIX_BYTES;
view.setUint8(0, EnvelopeLayout.FIRST);
view.setUint8(1, header.kind);
view.setUint8(2, flags);
view.setUint8(3, headerBytes / 4);
view.setUint32(4, header.callId);
…
return headerBytes;
};implementations/typescript/src/router/shared/header.tsEach of the ten, on GitHub:
constants.tsLayouts, kinds, flags, schemes, codes, limitsheader.tsThe header: written and read at fixed offsetsenvelope.tsThe whole envelope, and a failure’s recordtransfer.tsTransfers: the chunks sent, placed, and the creditidentifiers.tsThe callIds and the session tokenserrors.tsFailureError: a failure received, readableviolations.tsA PAYLOAD_INVALID’s violations: written as lines of JSON, read backbroker-parcel.tsA broker’s parcel: written and read at fixed offsetsmqtt-packets.tsAn MQTT 5 PUBLISH counted to the byte, its topics and its groupamqp-messages.tsAn AMQP 1.0 message counted to the byte, its profile, its subjects and its addresses
See it run, then read it The demo calls a server in a Web Worker of your page; the repository holds the specification, the three implementations and every decision.