WebSocket API
Metric Broker WebSocket
The bytes and json encodings, addresses, every op and op code, frames byte by byte, acknowledgements, fan-out, errors and close codes.
The Metric Broker has two endpoints with the same operations. Pick one per connection:
| Encoding | URL | Frames |
|---|---|---|
bytes | wss://api.thuum.dev/api/{domain}/metric/connect/bytes?token=… | binary only |
json | wss://api.thuum.dev/api/{domain}/metric/connect/json?token=… | text only |
The handshake, token, claim snapshot and keepalive are as on every broker
(WebSocket protocol): an invalid or expired token is refused with 401, a key
without a Metric Broker claim with 403. A frame of the wrong type (text on bytes, binary on
json) closes the connection with 1003.
Both encodings interoperate without loss: a measurement published over bytes reaches a json
subscriber as plain JSON values (see Values in JSON), and the other way round.
Messages#
A message holds one or more ops, in both directions: at most 1000 per message.
bytes:count:u16, thencountops back to back. Each op starts with its op code; its length follows from the code and the dtype in its Tag. Everything is little-endian.json: an array of op objects. An op on one series carries itsaddress("000C:3E01"), an op on a whole Group itsgroup("000C"), both hex strings (below);xandyare plain JSON values typed by the Tag's dtype (below);ackis an optional boolean. No base64 anywhere.
Replies to one message come back as one message, in op order. A message whose ops all succeed
without ack gets no reply at all.
Two formats, one broker#
bytes and json are two formats of the same protocol, handled the same way:
- One frame type per connection. The WebSocket standard marks each frame as text or binary. The
Metric Broker ties that type to the connection: a
/connect/bytesconnection sends and accepts binary frames only, a/connect/jsonconnection text frames only. So themeasmessages the server pushes have one unambiguous format: the one the subscriber chose. Control frames (ping, pong, close) are unaffected. A data frame of the other type closes the connection with1003. - Decoded at the edge. As a frame arrives, its format's decoder turns it into one internal form
of the ops. One core then does everything else (claims, storage, live delivery, subscriptions,
acks, the audit log), knowing neither format. Its replies and deliveries are encoded for each
connection in that connection's own format, so a
bytespublisher reaches ajsonsubscriber, and the other way round, without loss. - An
:errorreply echoes exactly what was sent. Inbytes, the op's original bytes, then the error code: anf32NaNsent as00 00 C0 7Fcomes back as00 00 C0 7F(example). In JSON, the op's own fields as written (address,xandyfor apub;addressforsubandunsub;groupfor the group ops), even when they are what's wrong. - One order of checks, both formats: the op's structure (the decoder), then the Tag's
dtype, then the claim, then the values. The first that fails answers. A malformed value
sent to a Group you may not publish to is
forbiddenin both formats.
What "structure" means, and what each format does when it is wrong, is the one difference: a
malformed bytes message can't be split into ops, so it closes the connection (1007, or 1009
for too many ops); a JSON op with a bad ack, address or group is answered with its own
:error (bad_request), and a message that isn't an array of ops with an error op. The
connection stays open.
Addresses in JSON#
In JSON, Groups and Tags are hex text, the way the concepts describe:
| Field | Form | Example | In |
|---|---|---|---|
address | GGGG:TTTT (4 + 4 hex digits) | "000C:3E01" | pub, sub, unsub, their replies, meas |
group | GGGG (4 hex digits) | "000C" | sub-group, unsub-group and their replies |
- Exactly 4 digits per part, leading zeros kept, so a Tag's first two digits are its dtype:
"000C:3E01"is Group000C(12), Tag3E01,sns_f32id 1. - Read in either case (
"000c:3e01"), always written uppercase in replies andmeas. An:errorreply repeats your fields as you wrote them. - No
0x, no decimal, no JSON numbers. Anything else answers the op's:errorwithbad_request:
| Sent | message |
|---|---|
"address": "12:3E01", "C:3E01" | address must be an address: 4 + 4 hex digits, e.g. "000C:3E01" |
"address": "0x000C:0x3E01", 12 | address must be an address: 4 + 4 hex digits, e.g. "000C:3E01" |
"group": "12", "0x0C", 12 | group must be a Group: 4 hex digits, e.g. "000C" |
The bytes encoding doesn't change: Group and Tag are two little-endian u16, and "000C:3E01"
travels as 0C 00 01 3E.
Values in JSON#
In the json encoding (and over REST, which uses the same rules), x and y are written the way
people read them. Numbers are JSON numbers, except 64-bit integers and times, which are strings:
| Value | In JSON | Examples |
|---|---|---|
y u8 u16 u32 i8 i16 i32, discrete d8 d16 d32 | a JSON integer, in the type's range | 200, -3, 4294967295 |
y f16 f32 f64; x f16 f32 | a JSON number (an integer is fine) | 23.5, 0.1, -2 |
y u64 i64 d64 | a decimal string, in the type's range, always | "18446744073709551615", "-42", "0" |
a time x (cts; server times in pub:ok and meas) | an ISO 8601 string, Z or an offset, up to 9 decimals | "2026-11-02T10:00:00.5Z", "2026-11-02T11:00:00+01:00" |
- 64-bit integers are always strings, whatever their value (
"0", not0), so a client never has to check which form it got. Digits only, with an optional leading-(leading zeros are accepted);+, spaces, decimals, exponents and hex are abad_request. f16andf32are rounded to the nearest value of their type (ties to even). The replies (pub:ok,meas) carry the value as stored: f320.1comes back as0.10000000149011612, f160.1as0.0999755859375, f1665519as65504. A value that rounds beyond the type's finite range is abad_request(f1665520, f323.5e38); one too small rounds to0(f161e-10).- Times go from
1970-01-01T00:00:00Zto2262-04-11T23:47:16.854775807Z(0to2^63 − 1ns). Write them asYYYY-MM-DDTHH:MM:SS, with up to 9 decimals andZor an offset±HH:MM(uppercaseTandZ). Replies always use UTC withZ, trailing zeros of the fraction dropped:"2026-11-02T11:00:00.500+01:00"comes back as"2026-11-02T10:00:00.5Z". - A value of the wrong JSON type is a
bad_request: a string for au8, a fraction for ani32, a number for au64or a time,truefor anything. Values are checked after the claim (order of checks), so a malformed value on a Group you may not publish to getsforbidden, as it would overbytes.
In JavaScript, the strings convert without loss:
const [meas] = JSON.parse(message.data); // { op: 'meas', address: '000C:3701', x, y }
const when = new Date(meas.x); // a time: milliseconds are enough for display
const counter = BigInt(meas.y); // a u64 y: "18446744073709551615" → 18446744073709551615n, exact@thuum/client does all of this for you: toJsonValue and fromJsonValue (and parseTime /
formatTime for full nanosecond times), or simply MetricBrokerSocket, which hands you numbers and
bigints (With the client).
Operations#
Each client op has a no-ack code (the default) and an ack code.
| Op | No-ack / ack | Bytes after the op code | JSON fields | Flag |
|---|---|---|---|---|
pub | 01 / 02 | group:2 tag:2 [x] y | address, x?, y, ack? | P |
sub | 03 / 04 | group:2 tag:2 | address, ack? | S |
sub-group | 05 / 06 | group:2 | group, ack? | S |
unsub | 07 / 08 | group:2 tag:2 | address, ack? | — |
unsub-group | 09 / 0A | group:2 | group, ack? | — |
The server sends:
| Op | Code(s) | Bytes after the op code | JSON fields |
|---|---|---|---|
pub:ok | 11 | group:2 tag:2 x y | address, x, y |
pub:error | 12 | group:2 tag:2 [x] y code:2 | address, x?, y, code, message |
sub:ok / sub:error | 13 / 14 | group:2 tag:2 (+ code:2) | address (+ code, message) |
sub-group:ok / sub-group:error | 15 / 16 | group:2 (+ code:2) | group (+ code, message) |
unsub:ok / unsub:error | 17 / 18 | group:2 tag:2 (+ code:2) | address (+ code, message) |
unsub-group:ok / unsub-group:error | 19 / 1A | group:2 (+ code:2) | group (+ code, message) |
meas | 21 | group:2 tag:2 x y | address, x, y |
[x]is present when the client setsx(x digit4–6) and absent with server time (0–3): apubwith server time carries nox(in JSON, anxis abad_request).- In
pub:okandmeas,xis always present: with server time, the time the server set (8 bytes inbytes, an ISO 8601 string in JSON).pub:errorrepeats thepubas it was sent. - An
:errorreply echoes the op as you sent it: inbytesits bytes, in JSON its own fields (address,x,yfor apub;addressforsubandunsub;groupfor the group ops), even when they are what's wrong ("group": "12"gets asub-group:errorwith"group": "12"), so you can match it to the op. - The sizes of
xandycome from the Tag: see Datatypes. Inbytes, an op's length is1(code)+ 2(group)+ 2(tag, if any)+ x + y(+ 2for an error code).
Error codes#
| Number | JSON code | Meaning |
|---|---|---|
1 | forbidden | The connection's claim lacks the flag on this Group (P to publish, S to subscribe). message: Missing P claim on Group 0063. |
2 | bad_request | An invalid dtype in the Tag, a missing or extra x, and: in bytes, a NaN or infinite float or a time above 2^63 − 1; in JSON, a malformed address, group or ack, or a value of the wrong JSON type, out of its type's range, or a malformed time. |
3 | unknown_op | JSON only: an op the broker doesn't know, or none (an error op). In bytes, an unknown op code closes the connection instead. |
4 | limit | Reserved for limits on what a Domain may do; not sent today. |
5 | internal | The measurement could not be stored (pub:error); nothing was stored. Safe to retry. |
In bytes the code is a u16 (01 00 is forbidden) and there is no message. JSON replies use
the name and add a human-readable message, for example:
| Cause (JSON) | message |
|---|---|
"address": "12:3E01" | address must be an address: 4 + 4 hex digits, e.g. "000C:3E01" |
"group": 12 | group must be a Group: 4 hex digits, e.g. "000C" |
"ack": "yes" | ack must be a boolean |
"address": "000C:7C01" (dtype 7C) | Tag 7C01 has dtype 7C, which is not valid |
A pub to Group 0063 without P | Missing P claim on Group 0063 |
"y": "23.5" on an f32 Tag | y must be a number (f32) |
"y": 3.5e38 on an f32 Tag | y must be a number within the f32 range |
"y": 1.5 (or "7") on a u8 Tag | y must be an integer (u8) |
"y": 256 on a u8 Tag | y must be from 0 to 255 (u8) |
"y": 42 on a u64 Tag | y must be a decimal string (u64) |
"y": "18446744073709551616" on a u64 Tag | y must be from 0 to 18446744073709551615 (u64) |
"x": 1793613600000000000 on a cts Tag | x must be an ISO 8601 time from 1970-01-01T00:00:00Z to 2262-04-11T23:47:16.854775807Z |
An x on a server-time Tag | x must be absent for sns_f32: the server sets it |
No x on a client-time Tag | x is required for cts_f64 |
No y | y is required |
The rows are in the order the checks run: structure, dtype, claim, values. Only the first failure is reported.
Frames, byte by byte#
All examples use Group 000C (12). Bytes are shown in wire order: Group and Tag are little-endian,
so the address 000C:3E01 is written 0C 00 01 3E.
One op at a time#
sns_f32 pub (1 + 2 + 2 + 0 + 4 = 9 B)
01 op pub
0C 00 group 000C (12)
01 3E tag 3E01 (sns_f32, id 1)
x none: the server sets it
00 00 BC 41 y 23.5
sns_f32 meas (1 + 2 + 2 + 8 + 4 = 17 B)
21 op meas
0C 00 group 000C (12)
01 3E tag 3E01 (sns_f32, id 1)
00 A5 1C 12 11 32 E4 18 x 1793613600500000000 ns = 2026-11-02T10:00:00.5Z (server)
00 00 BC 41 y 23.5
cts_f64 pub (1 + 2 + 2 + 8 + 8 = 21 B)
01 op pub
0C 00 group 000C (12)
01 4F tag 4F01 (cts_f64, id 1)
00 40 4F F4 10 32 E4 18 x 1793613600000000000 ns = 2026-11-02T10:00:00Z (client)
00 00 00 00 00 80 37 40 y 23.5
f16_u8 pub (1 + 2 + 2 + 2 + 1 = 8 B)
01 op pub
0C 00 group 000C (12)
07 54 tag 5407 (f16_u8, id 7)
00 3C x 1.0
03 y 3Whole messages#
Two ops in one message, no acks (14 B). Nothing comes back unless an op fails:
→ 02 00 count 2
01 0C 00 01 3E 00 00 BC 41 pub sns_f32, y 23.5 (9 B)
05 0C 00 sub-group 000C (3 B)A subscription with an ack (5 B), and its reply (5 B):
→ 01 00 06 0C 00 count 1, sub-group (ack), group 000C
← 01 00 15 0C 00 count 1, sub-group:ok, group 000CA publish with an ack (11 B). The reply (19 B) adds the time the server set:
→ 01 00 02 0C 00 01 3E 00 00 BC 41
← 01 00 11 0C 00 01 3E 00 A5 1C 12 11 32 E4 18 00 00 BC 41
└op┘└grp┘└tag┘└────────── x ─────────┘└─── y ───┘The same publish without the P flag on Group 000C (the reply is always sent):
← 01 00 12 0C 00 01 3E 00 00 BC 41 01 00
pub:error, the pub as sent, code 1 (forbidden)An error echoes the op#
A publish (ack) of an f32 NaN, with the P flag: the claim passes, the value doesn't, and the
pub:error repeats the op's bytes exactly, NaN included, then code 2 (bad_request):
→ 01 00 02 0C 00 01 3E 00 00 C0 7F
pub (ack), 000C, 3E01, y NaN
← 01 00 12 0C 00 01 3E 00 00 C0 7F 02 00
pub:error, the pub as sent (NaN included), code 2 (bad_request)Without the P flag, the same frame gets code 1 (forbidden): the claim is checked before the
value.
The same in JSON#
The same exchanges over json, with every kind of value (addresses in Group 000C; Tags 3E01
sns_f32, 4F01 cts_f64, 3701 sns_u64, 5D01 f16_f16):
→ [{ "op": "sub-group", "group": "000C", "ack": true }]
← [{ "op": "sub-group:ok", "group": "000C" }]
→ [{ "op": "pub", "address": "000C:3E01", "y": 23.5, "ack": true }]
← [{ "op": "pub:ok", "address": "000C:3E01", "x": "2026-11-02T10:00:00.5Z", "y": 23.5 }]
← [{ "op": "meas", "address": "000C:3E01", "x": "2026-11-02T10:00:00.5Z", "y": 23.5 }]
→ [{ "op": "pub", "address": "000c:3e01", "y": 0.1, "ack": true }]
← [{ "op": "pub:ok", "address": "000C:3E01", "x": "2026-11-02T10:00:01.25Z", "y": 0.10000000149011612 }]
→ [{ "op": "pub", "address": "000C:4F01", "x": "2026-11-02T10:00:00Z", "y": 23.5 }]
→ [{ "op": "pub", "address": "000C:3701", "y": "18446744073709551615", "ack": true }]
← [{ "op": "pub:ok", "address": "000C:3701", "x": "2026-11-02T10:00:02Z", "y": "18446744073709551615" }]
→ [{ "op": "pub", "address": "000C:5D01", "x": 0.1, "y": 1.5, "ack": true }]
← [{ "op": "pub:ok", "address": "000C:5D01", "x": 0.0999755859375, "y": 1.5 }]
→ [{ "op": "pub", "address": "000C:3701", "y": 42 }]
← [{ "op": "pub:error", "address": "000C:3701", "y": 42, "code": "bad_request", "message": "y must be a decimal string (u64)" }]
→ [{ "op": "pub", "address": "0063:3E01", "y": "hot" }]
← [{ "op": "pub:error", "address": "0063:3E01", "y": "hot", "code": "forbidden", "message": "Missing P claim on Group 0063" }]
→ [{ "op": "sub", "address": "12:3E01" }]
← [{ "op": "sub:error", "address": "12:3E01", "code": "bad_request", "message": "address must be an address: 4 + 4 hex digits, e.g. \"000C:3E01\"" }]- The connection is subscribed to Group
000C, so it also receives ameasfor each publish (shown once). - The address is read in either case:
"000c:3e01"is answered as"000C:3E01". - The f32
0.1is stored as the nearestf32,0.10000000149011612, and that is what comes back, inpub:ok, in everymeasand in REST. Overbytesit isCD CC CC 3D: the same stored value. The f16x0.1likewise comes back as0.0999755859375. - The
u64is a string both ways, even for small values;42as a number is refused, and thepub:errorrepeats it as sent. - Without
Pon Group0063, thepubisforbiddenbefore its value ("hot", not a number) is looked at: the same order as overbytes. - An
:errorrepeats only the op's own fields, as sent ("12:3E01"), never theack.
Problems that can't be tied to one op (invalid JSON, not an array, more than 1000 ops, an op that
isn't an object) are answered with one error op, and an unknown op names what it received. The
connection stays open:
← [{ "op": "error", "code": "bad_request", "message": "A message must be a non-empty JSON array of op objects" }]
← [{ "op": "error", "code": "unknown_op", "received_op": "publish", "message": "Unknown op \"publish\"; expected one of \"pub\", \"sub\", \"sub-group\", \"unsub\", \"unsub-group\"" }]Those error ops have no Group, so they aren't written to the audit log.
Fan-out and delivery#
A measurement at address GGGG:TTTT reaches:
- the connections subscribed to the whole Group (
sub-groupGGGG); and - the connections subscribed to that exact address (
subGGGG:TTTT).
- Once per connection, even when it is subscribed both ways, and publisher included.
- Stored first, then delivered.
pub:okmeans stored; thepubs of one message are stored in one batch. - The measurements one message publishes reach each subscriber as one message per Group, of
measops in publish order: a message publishing to Groups0015and0016arrives as two messages to a connection subscribed to both. - Live delivery is best-effort: catch up over REST (
rawfrom the lastxyou received). sub,unsuband the group forms are idempotent.unsubneeds no flag, and doesn't undo asub-group(norunsub-groupasub). Asubto a Tag with an invalid dtype is asub:errorwithbad_request.
Close codes#
| Code | Why |
|---|---|
1003 | A frame of the wrong type: text on /connect/bytes, binary on /connect/json. |
1007 | bytes only: a malformed message. count is 0, an op is truncated, an op code is unknown, a pub's Tag has an invalid dtype, or bytes are left over. |
1008 | The Domain was deactivated or released. |
1009 | bytes only: more than 1000 ops in a message. Either encoding: a frame larger than 256 KiB. |
1011 | An unexpected server error. Its details are never logged, because they could hold your data. Reconnect. |
A malformed JSON message doesn't close the connection: it is answered with an error op (above).
To see each close for yourself, send a frame by hand from the Live tool of the
Playground's Metric Broker:
Minimal client without a library#
A bytes connection in plain browser JavaScript: subscribe to a Group, publish a reading, and decode
what comes back.
const { token } = await fetch('/api/thuum-token').then((r) => r.json());
const ws = new WebSocket(
`wss://api.thuum.dev/api/acme/metric/connect/bytes?token=${encodeURIComponent(token)}`
);
ws.binaryType = 'arraybuffer';
const xBytes = (tag) => [8, 8, 8, 8, 8, 2, 4][tag >> 12]; // x as the server sends it
const yBytes = (tag) => 1 << ((tag >> 8) & 3);
const CODES = ['', 'forbidden', 'bad_request', 'unknown_op', 'limit', 'internal'];
function decode(buffer) {
const v = new DataView(buffer);
const ops = [];
let at = 2;
for (let n = v.getUint16(0, true); n > 0; n--) {
const code = v.getUint8(at);
const op = { code, group: v.getUint16(at + 1, true) };
at += 3;
if (![0x15, 0x16, 0x19, 0x1a].includes(code)) {
op.tag = v.getUint16(at, true);
at += 2;
}
if (code === 0x11 || code === 0x12 || code === 0x21) {
const x = code === 0x12 && op.tag >> 12 <= 3 ? 0 : xBytes(op.tag); // pub:error: as sent
op.x = new DataView(buffer, at, x);
op.y = new DataView(buffer, at + x, yBytes(op.tag));
at += x + yBytes(op.tag);
}
if (code >= 0x11 && code <= 0x1a && code % 2 === 0) {
op.error = CODES[v.getUint16(at, true)];
at += 2;
}
ops.push(op);
}
return ops;
}
ws.addEventListener('message', ({ data }) => {
for (const op of decode(data)) {
if (op.code === 0x21 && op.tag === 0x3e01) {
const ns = op.x.getBigUint64(0, true);
console.log(new Date(Number(ns / 1_000_000n)), op.y.getFloat32(0, true));
} else if (op.error) {
console.warn('error', op);
}
}
});
await new Promise((resolve) => ws.addEventListener('open', resolve, { once: true }));
// count 1 · sub-group (ack) · group 000C
const sub = new DataView(new ArrayBuffer(5));
sub.setUint16(0, 1, true);
sub.setUint8(2, 0x06);
sub.setUint16(3, 0x000c, true);
ws.send(sub.buffer);
// count 1 · pub · address 000C:3E01 (sns_f32, id 1) · y 23.5
const pub = new DataView(new ArrayBuffer(11));
pub.setUint16(0, 1, true);
pub.setUint8(2, 0x01);
pub.setUint16(3, 0x000c, true);
pub.setUint16(5, 0x3e01, true);
pub.setFloat32(7, 23.5, true);
ws.send(pub.buffer);With the client#
@thuum/client speaks both encodings through one socket class, MetricBrokerSocket (json by
default), and encodes and decodes the frames for you: values are numbers, and times and 64-bit
integers bigint. From a domain client, or directly with a token your server gave the browser:
import { MetricBrokerSocket, formatTime, nowTime } from '@thuum/client';
const socket = acme.metricBroker.connect({ encoding: 'bytes' });
// or: new MetricBrokerSocket({ baseUrl: 'https://api.thuum.dev', domain: 'acme', token, encoding: 'bytes' })
socket.onMeas(({ address, x, y }) => console.log(address, formatTime(x as bigint), y));
socket.onError((error) => console.warn(error.code, error.message)); // every error, ack or not
await socket.ready;
await socket.subGroup('000C', { ack: true }); // resolves on sub-group:ok
await socket.pub({ address: '000C:3E01', y: 23.5 }); // sns_f32; no ack: resolves once sent
const stored = await socket.pub(
{ address: '000C:4F01', x: nowTime(), y: 21.5 }, // cts_f64: the client sets x
{ ack: true } // resolves once stored, with x as stored
);
const results = await socket.send([
{ op: 'pub', group: 0x000c, tag: 0x3e01, y: 24, ack: true }, // raw ops: Group and Tag as numbers
{ op: 'sub', group: 0x000d, tag: 0x3e01, ack: true }
]); // one message; one { status: 'sent' | 'ok' | 'error' } per opThe socket takes addresses as text ('000C:3E01', '000C') or as numbers ({ group: 12, tag: 0x3e01 }, 12) and writes them in the connection's format; measurements and replies carry both
(address, group, tag).
An ack op rejects with MetricBrokerSocketError (forbidden, bad_request, …, or closed); a
value that doesn't fit the Tag's dtype rejects with MetricCodecError before anything is sent. Over
json the socket writes values by the rules above (an f32 0.1 goes out as
0.10000000149011612, a bigint u64 as a decimal string) and also accepts their JSON string
forms as input: an ISO 8601 time for x, a decimal string for a 64-bit y. See
Sockets.