Build a WebSocket channel
Design message envelopes
Give every WebSocket message a type, identifier, version, and validation path.
WebSockets move bytes. They do not tell you what those bytes mean, who may send them, or how to upgrade the format next month.
We wrap every frame in a documented envelope: type, id, version, and payload. Commands get a client id; acknowledgements reference it; server events carry their own event id for deduplication.
Four message kinds
// command from operator
{
"type": "command.ack_incident",
"id": "cmd_91",
"v": 1,
"payload": { "incidentId": 42, "note": "Failover complete" }
}
// acknowledgement back to operator
{
"type": "ack",
"id": "ack_120",
"v": 1,
"payload": { "commandId": "cmd_91", "status": "applied" }
}
// server event (same shape idea as SSE)
{
"type": "event.incident.updated",
"id": "evt_87",
"v": 1,
"payload": { "incidentId": 42, "status": "resolved" }
}
// error
{
"type": "error",
"id": "err_3",
"v": 1,
"payload": { "code": "unknown_type", "message": "rejecting frame" }
}
Parse safely on both sides
function handleFrame(raw) {
let msg
try {
msg = JSON.parse(raw)
} catch {
return sendError('invalid_json')
}
if (!msg.type || !msg.v) {
return sendError('missing_fields')
}
switch (msg.type) {
case 'command.ack_incident':
return handleAckCommand(msg)
default:
return sendError('unknown_type')
}
}
Send malformed JSON and an unknown type. The peer should answer with a structured error, not throw and crash the connection.
Correlate commands
When the operator sends cmd_91, disable the button until ack with commandId: "cmd_91" arrives or a timeout fires. That correlation is how you detect lost commands without guessing from silence.
Document the envelope in one markdown file and treat unknown types as a version mismatch, not as “maybe it works.”
One failure to reproduce
Paste this frame into your handler:
{not valid json
The server should answer with error and invalid_json, not throw an uncaught exception. Paste this next:
{"type":"command.delete_everything","v":99}
The server should reject unknown_type because no client in production should carry that permission.
When you add a new type, bump documentation first, deploy the server, then deploy clients. Doing it backwards creates silent drops.
Keep a single schemas/envelope.v1.json in the repo if you want CI to reject frames before they reach production handlers.
The envelope is the contract both teams share. When product adds a field, update the schema, the server validator, and the client parser in one pull request.
Try this on your own project: define command, ack, event, and error envelopes, then test malformed JSON and an unsupported version number.
Lesson completed