Opcodes
The numeric wire contract between client and server, for anyone writing their own client.
You do not need this page to use Blackevin — @blackevin/client speaks all of it. You
need it to write a client in a language that has no SDK yet.
The opcodes
| op | name | direction | role |
|---|---|---|---|
| 1 | Attach | C→S | mark the channel attached — the gate for presence delivery |
| 2 | Detach | C→S | unmark it |
| 3 | Publish | C→S | publish a message |
| 4 | Message | S→C | a delivered publish |
| 5 | PresenceEnter | C→S | enter presence |
| 6 | PresenceLeave | C→S | leave presence |
| 7 | PresenceUpdate | C→S | update presence data |
| 8 | Presence | S→C | a presence change |
| 9 | Ack | S→C | success, for a request that carried an id |
| 10 | Nack | S→C | failure, with code and message |
| 11 | Heartbeat | C↔S | keepalive |
| 12 | Connected | S→C | welcome, carrying connectionId |
| 13 | Subscribe | C→S | subscribe to a channel |
| 14 | Unsubscribe | C→S | unsubscribe |
| 15 | Auth | C→S | swap the credential mid-session |
The numbers are frozen. The names in this table are for humans; nothing on the wire carries them. A client that switches on the number will keep working across releases, and one that expects a name will not work at all.
Every frame
{
op: number; // required
id?: string; // client-generated; the server echoes it in Ack / Nack
}id is what makes a request awaitable. Send it on anything whose outcome you
care about — a Publish without one is fire-and-forget, because there is nothing
for the Ack to name.
The frames that carry more
Publish and Message add channel, an optional name, and optional JSON
data. Message also carries the publishing connectionId and a timestamp.
Presence frames add channel, clientId and optional data. The server's
Presence adds action (enter / leave / update) and timestamp.
Subscribe may add rewind — deliver the last N persisted messages to this
connection immediately after the Ack, oldest first. The server caps it at 100.
It may also add filter: a string compiled server-side as a RegExp and
applied after the structural channel match.
Auth carries accessToken or key. The server answers Ack and a fresh
Connected.
The handshake
C → (open socket, credential in the query string)
S → Connected { connectionId }
C → Subscribe { id: "1", channel: "chat:lobby" }
S → Ack { id: "1" }
S → Message { channel: "chat:lobby", name: "…", data: {…} }The credential goes on the connect URL:
ws://host/?key=secret.keyId
ws://host/?accessToken=<blackevin-jwt>token is accepted as an alias for accessToken. A missing or invalid
credential gets a Nack with code 401 and no further operations are served.
Nack codes
They are HTTP status codes, because they mean the same things:
| code | means |
|---|---|
| 401 | missing, malformed, unknown or revoked credential |
| 403 | authenticated, but the capability does not cover this channel and operation |
| 429 | a connection ceiling was hit |
| 4000 | disconnected by the server |
A 403 leaves the connection open — one refused publish should not cost the session. A 401 does not: nothing further is served on that socket.
401 and 403 are terminal for a client: the credential will not start working. 429 is not — a slot frees when another client disconnects. See connection state.
Attach is not subscribe
They look redundant and are not.
- Subscribe is about messages: it registers the channel matcher that decides what gets delivered.
- Attach is about presence: it is the gate that decides whether this
connection receives
Presenceframes for the channel.
@blackevin/client attaches implicitly on the first subscribe or publish, so most
clients never send a bare Attach. One that only wants to watch presence — a
dashboard that never reads messages — is the case where you would.
Not yet on the wire
Binary frames, and connection resume with recovery serials. A reconnect today
re-sends Attach and Subscribe rather than resuming a stream, which is why
rewind is how a client catches up.