@polynomialhq/contracts
Zod schemas, inferred message types, and codecs for WebSocket protocol version 1.
pnpm add @polynomialhq/contractsMost applications do not need this package directly. Use it when implementing a raw WebSocket client, a worker integration, protocol logging, or a custom transport boundary.
Watch the protocol
Every frame below is produced by the real client and validated by these schemas.
A room join is always the same three steps: the client sends room.join, the service answers
room.joined, and the service immediately follows with yjs.sync carrying the persisted document.
Protocol version
import { websocketProtocolVersion } from "@polynomialhq/contracts";
console.log(websocketProtocolVersion); // 1Every client and server message includes v: 1. The version is a literal in every schema, so a
message from a different protocol version fails validation rather than being partially parsed.
Union schemas
clientWebSocketMessageSchema
Validates every message sent from a client to the realtime service:
const message = clientWebSocketMessageSchema.parse(input);The inferred union type is ClientWebSocketMessage.
serverWebSocketMessageSchema
Validates every message received from the realtime service. The inferred union type is
ServerWebSocketMessage.
Both are discriminated on type, so switch (message.type) narrows exhaustively in TypeScript.
Parse, decode, and encode
Use parse* for an already parsed unknown value:
const message = parseServerWebSocketMessage(JSON.parse(event.data));Use decode* for a JSON string:
const message = decodeServerWebSocketMessage(event.data);Use encodeWebSocketMessage() for either message union:
socket.send(
encodeWebSocketMessage({
nonce: "health-check",
type: "ping",
v: websocketProtocolVersion,
}),
);The full codec exports are:
parseClientWebSocketMessage(input)parseServerWebSocketMessage(input)decodeClientWebSocketMessage(input)decodeServerWebSocketMessage(input)encodeWebSocketMessage(message)
All of the parse and decode helpers use Zod's parse, so invalid input throws a ZodError. Use
schema.safeParse() directly when you need a result object instead.
Client message schemas
| Schema | type | Important fields |
|---|---|---|
clientJoinRoomMessageSchema | room.join | token, optional roomId |
clientLeaveRoomMessageSchema | room.leave | — |
clientPresenceUpdateMessageSchema | presence.update | presence |
clientBroadcastMessageSchema | broadcast | event, payload |
clientYjsUpdateMessageSchema | yjs.update | encoding, update, optional updateId |
clientPingMessageSchema | ping | optional nonce |
All are Zod schemas and can be used individually:
const update = clientPresenceUpdateMessageSchema.parse({
presence: {
cursor: { x: 24, y: 80 },
},
type: "presence.update",
v: 1,
});roomId on room.join is optional because the token already names a room. Send it when the
credential is a publishable key, which is only accepted for a connection that names its room.
Including updateId on yjs.update opts into acknowledgement: the service replies with yjs.ack
once the update is persisted, which is how the client knows an update is durable rather than merely
sent.
Server message schemas
| Schema | type | Important fields |
|---|---|---|
serverRoomJoinedMessageSchema | room.joined | connection, user, room, role, ack capability |
serverRoomLeftMessageSchema | room.left | connectionId |
serverPresenceUpdatedMessageSchema | presence.updated | connection, user, presence |
serverPresenceSyncMessageSchema | presence.sync | current room presences |
serverBroadcastMessageSchema | broadcast | sender, event, payload |
serverYjsUpdateMessageSchema | yjs.update | sender and base64 update |
serverYjsSyncMessageSchema | yjs.sync | sequenced persisted updates |
serverYjsAcknowledgedMessageSchema | yjs.ack | sequence, updateId |
serverErrorMessageSchema | error | code, message, retryable |
serverPongMessageSchema | pong | optional nonce |
room.joined carries yjsUpdateAcknowledgements: true only when the room is backed by durable
storage. When it is absent, the client stops tracking pending updates because none will ever be
acknowledged.
presence.updated, broadcast, and yjs.update are fanned out to every connection in the room,
including the connection that sent them. Compare the connectionId on the message with your own to
tell the difference.
retryable on serverErrorMessageSchema defaults to false, so it may be omitted on the wire and
is always present after parsing.
Roles and errors
roomRoleSchema validates "read" | "write" | "admin" and infers RoomRole.
websocketErrorCodeSchema validates:
| Code | Retryable | Typical cause |
|---|---|---|
unauthorized | No | Invalid, expired, or missing room token |
forbidden | No | The token's role does not allow the operation |
not_found | No | The room or project does not exist |
invalid_message | No | The frame failed schema validation |
rate_limited | Usually | Too many messages from one connection |
internal_error | Usually | The service could not persist or prepare room state |
The inferred type is WebSocketErrorCode. Retryability is carried per message on the retryable
field — read that rather than inferring it from the code.
Raw browser example
import {
decodeServerWebSocketMessage,
encodeWebSocketMessage,
} from "@polynomialhq/contracts";
const socket = new WebSocket("wss://polynomialhq.live/v1/realtime");
socket.addEventListener("open", () => {
socket.send(
encodeWebSocketMessage({
token,
type: "room.join",
v: 1,
}),
);
});
socket.addEventListener("message", (event) => {
const message = decodeServerWebSocketMessage(String(event.data));
if (message.type === "room.joined") {
console.log(`Joined ${message.roomId}`);
}
});Prefer @polynomialhq/client in product code. It adds connection lifecycle, authentication,
reconnection, presence snapshots, event typing, and storage synchronization above these schemas.