francisdesjardins.ca Bead Français

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
  1. An envelope of 8 bytes at fixed offsets, then the payload.
  2. A callId pairs each answer with its call, in both directions.
  3. A failure carries a code whose block says the stage it reached.
  4. Subscriptions at the client’s pace, and transfers under credit.
  5. A credential on each call, which relays forward.
  6. 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).

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.

Calls in both directions
ServerClientodd, the client opened the call · even, the serverREQUEST · callId 3 · service 2, procedure 1RESULT · callId 3REQUEST · callId 5 · service 2, procedure 9FAILURE · callId 5 · PROCEDURE_UNKNOWNREQUEST · callId 2 · a command from the serverRESULT · callId 2

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
CodeNameStageFateWhen
1ENVELOPE_MALFORMEDenvelopegateThe bytes do not form an envelope this layout reads.
2LAYOUT_UNSUPPORTEDenvelopegateA layout to come, 0xA2 to 0xAF; the detail says the ones read.
3KIND_UNSUPPORTEDenvelopereportedA kind to come, 0x17 to 0x7F, or a NOTIFICATION a server received.
4ENVELOPE_TOO_LARGEenvelopegateA request past chunkBytes, or another envelope past maximumEnvelopeBytes.
16DESTINATION_UNKNOWNrouteansweredA destination the relay does not have, or a peer that relays nothing.
17DESTINATION_UNREACHABLErouteansweredThe request could not leave for its destination: nothing was tried.
18SERVICE_UNKNOWNrouteansweredNo procedure is registered for this service.
19PROCEDURE_UNKNOWNrouteansweredThe service exists, not the procedure.
20CALL_UNKNOWNroutereportedA RESULT, a FAILURE or a CHUNK no call awaits.
21SUBSCRIPTION_UNKNOWNroutereportedA NOTIFICATION under no subscription.
22SUBSCRIPTION_UNSUPPORTEDrouteansweredA subscription on a path with no notification: a relay, an HTTP connection that does not listen.
32SESSION_REQUIREDadmissionansweredThe credential does not hold, or the procedure wants a session the call lacks; also sent when a session ends.
33ACCESS_DENIEDadmissionansweredThe admission refuses the call, on its header and its session.
34PAYLOAD_TOO_LARGEadmissionrejectedThe payload is past its procedure’s or its router’s limit, 16 MiB by default.
35CAPACITY_EXCEEDEDadmissionansweredToo many calls at once, no free callId left, or a full queue or budget.
36CREDENTIAL_UNSUPPORTEDadmissionansweredA credential scheme this peer does not take; the detail names its own.
48CHUNK_INVALIDpayloadansweredA chunk outside the rules: empty, misplaced, of the wrong size or past the total.
49CREDIT_EXCEEDEDpayloadansweredA chunk past the credit granted.
50PAYLOAD_UNDECODABLEpayloadansweredThe procedure’s codec refuses the payload.
51TRANSFER_STALLEDpayloadansweredNothing came for a transfer for 30 s, by default.
52PAYLOAD_INVALIDpayloadansweredThe payload reads, but breaks a rule of its message.
64PROCEDURE_FAILEDprocedureansweredThe procedure, its admission or a credential’s verifier failed.
65DESTINATION_LOSTprocedureansweredThe 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.

The gate
noyesyesnoyesnoyesnoyesnomessage receivedprefixreadable?reportedENVELOPE_MALFORMEDenvelopereadable?dispatchedby kindREQUEST of thepeer's parity,callId free?FAILUREon that callIdRESULT, FAILUREor CHUNK 0xA1of a pending callor of a transferreceived?the call or the transfer ends:local rejection and CANCEL,or FAILURE CHUNK_INVALIDon a reserved callIdor a call given up?arrived late:dropped in silencereported,never answered

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.

A request its rules refuse, then a value judged before it goes
ServerClientthe payload reads, then is judged:from 10 at most does not holdthe resolver never saw itvalidates: the value judged before it goesfrom 11: PAYLOAD_INVALID,the same detail, nothing is sentREQUEST · callId 9 · CountDownRequest, from 12FAILURE · callId 9 · PAYLOAD_INVALIDone violation, then {"more":0}REQUEST · callId 11 · from 3RESULT · callId 11
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.

A call given up
ServerClienttimeout expired5 reserved: what arriveslate is droppedthe call forgotten, its subscription undone,then the resolver's signal abortedREQUEST · callId 5CANCEL · callId 5RESULT · callId 5, sent before the CANCEL:dropped in silence

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.

A paced subscription, its callback fetching
ServerClientthe callback fetches the articlechanges 2 to 45 queued,under one key: the last keptthe callback is doneREQUEST · callId 7 · SUBSCRIPTION, PACEDRESULT · callId 7NOTIFICATION · callId 7 · article 7, change 1CREDIT · callId 7 · PACED · 2NOTIFICATION · callId 7 · article 7, change 45
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 session on a connection
ServerClientsession timeout expiredthe connection stays, for another sessionREQUEST · callId 1 · open a sessionRESULT · callId 1 · CREDENTIAL OPAQUE: the tokenREQUEST · callId 3RESULT · callId 3 · no block: nothing changedREQUEST · callId 5 · subscribeRESULT · callId 5FAILURE · callId 5 · SESSION_REQUIREDCREDENTIAL NONE: the session has ended

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.

A transfer, then a refusal at the opening
ServerClientrouted, admitted,held within the budgetwhole: the procedure answersrefused at the opening:no credit, no chunkREQUEST · callId 7 · TRANSFER of 20 MiBits first MiB, with no creditCREDIT · callId 7 · up to 5 MiBCHUNK · callId 7 · from 1 MiB, MORECHUNK · callId 7 · …CREDIT · callId 7 · furtherCHUNK · callId 7 · the lastRESULT · callId 7REQUEST · callId 9 · TRANSFER, its first MiBFAILURE · callId 9 · ACCESS_DENIED

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.

A relayed call, then a refusal at the opening
Destination 7Gateway, the relayClientadmitted on its scheme,7 registered, its audiencenamed by aud,under both limitsverifies the JWT:the call runsin its session8 verifies another audience:refused at the opening, nothing leaves for the destinationREQUEST · callId 3 · DESTINATION 7CREDENTIAL JWTREQUEST · callId 1 · leg BCREDENTIAL JWT: the same bytesRESULT · callId 1RESULT · callId 3REQUEST · callId 5 · DESTINATION 8CREDENTIAL JWTFAILURE · callId 5DESTINATION_UNREACHABLE
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 long poll: a batch, its acknowledgement, then nothing
ServerClientnothing to report:the listen is helda NOTIFICATION, no. 1,enters the outboxno. 1 is acknowledged, forgottenPOST · bead-listen ?1201 · bead-connection-token,bead-listen ?1GET /bead · bead-after: 0bead-wait: 25000200 · a batch, from no. 1GET /bead · bead-after: 1bead-wait: 25000204 · nothing, at the end of the wait

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.

A server, its connections from two joined transports
HTTP and JSONone connectionper exchangea WebSocket clientthe WebSockettransportcurl, a page,an OpenAPI clientthe HTTP/JSON gatewayone more clientthe in-memorytransportthe joined transportties each handleto its transportthe serverone router, one admission,sessions, eachservice offered once
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.

A connection over a broker, in three legs
Server, a member of the groupBrokerClientthe connection toldto the router, its handleOPEN · to the name interopits half A, its boundthe OPEN, to a single memberACCEPT · to the client's inboxhalves A and Bthe ACCEPTPING · sequence 2halves A and Bthe PING, the third legDATA · a REQUESTthe DATA, inbox to inboxDATA · its RESULTthe DATA

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.

One connection per exchange: what it holds ends with it
ServerHTTP/JSON gatewayHTTP clientits gates passed:a connection drawn, in memorythe connection closed,and what it held with itthe admission wants a connectionsayHello greetedPOST /api/Hello/sayHello{"name":"Ada"}REQUEST · callId 1 · the proto payloadRESULT · callId 1200 · application/json{"greeting":"Hello, Ada!"}POST /api/Hello/countDown{"from":3}REQUEST · callId 1 · a fresh connectionFAILURE · callId 1 · ACCESS_DENIED403 · application/problem+json"detail":"say hello first"

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.

StatusWhat raised it
400PAYLOAD_UNDECODABLE, a reading refused; an application’s failure
401SESSION_REQUIRED, its challenge in www-authenticate
403ACCESS_DENIED
413ENVELOPE_TOO_LARGE, PAYLOAD_TOO_LARGE
422PAYLOAD_INVALID, its violations in errors
429CAPACITY_EXCEEDED, retry-after: 1
500PROCEDURE_FAILED, and what is said only between the gateway and its server
501SERVICE_UNKNOWN, PROCEDURE_UNKNOWN
502an answer the gateway could not take; DESTINATION_LOST
503DESTINATION_UNREACHABLE, retry-after: 1
504TRANSFER_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)
A server’s gates, from the address to the procedure
yesyesyesyesyesyesyesyesa requestaddress reachable?no: no sockethost taken?no: 403 host-refusedorigin taken?no: 403 origin-refuseda free place?no: 35 CAPACITY_EXCEEDEDprocedure known?no: 18 or 19, …_UNKNOWNcredential holds?no: 36 or 32, like a 401admission accepted?no: 33 ACCESS_DENIED, like a 403payload read and valid?no: 50 or 52the procedure runs

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 check refuses 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 exp plus 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 _sum is 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 come0xA2 to 0xAF: refused LAYOUT_UNSUPPORTED, the detail saying the layouts read.
  • A kind to come0x17 to 0x7F: reported KIND_UNSUPPORTED.
  • An advisory kind0x80 to 0xFF: 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.

From the .proto to each language’s code
refusedpassespassespassesagainst the last release.protoits bead.* and buf.validate optionsbufcompiles it into an imagethe front endreads the services, the options,the requests' rulesand the google.api.http routesthe blueprintbead-blueprint/1, JSONbeadc checkits rulesfindingsfile:line:columnTypeScript targetserver, client, validatorsand route tableC# targetserver, client, validatorsand route tablean OpenAPI 3.1.1document, embeddedbyte for bytethe messagesprotoc-gen-es, protoc C#beadc breakingWIRE or API,HTTP under both

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, json and proto.
  • 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.mjs fuzzes 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.toml

Every 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 beadc compiles, 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 GOAWAY drains 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.

Where Bead lives
ServerClientServiceswhat is calledRouterwrites and reads BeadTransportbytesTransportbytesRouterwrites and reads BeadServiceswhat answersone envelopeper WebSocket message

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.ts

And 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.ts

Each of the ten, on GitHub:

  • constants.tsLayouts, kinds, flags, schemes, codes, limits
  • header.tsThe header: written and read at fixed offsets
  • envelope.tsThe whole envelope, and a failure’s record
  • transfer.tsTransfers: the chunks sent, placed, and the credit
  • identifiers.tsThe callIds and the session tokens
  • errors.tsFailureError: a failure received, readable
  • violations.tsA PAYLOAD_INVALID’s violations: written as lines of JSON, read back
  • broker-parcel.tsA broker’s parcel: written and read at fixed offsets
  • mqtt-packets.tsAn MQTT 5 PUBLISH counted to the byte, its topics and its group
  • amqp-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.