PolynomialHQdocs
SDKs

@polynomialhq/contracts

Zod schemas, inferred message types, and codecs for WebSocket protocol version 1.

pnpm add @polynomialhq/contracts

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

Loading the protocol demo…

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); // 1

Every 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

SchematypeImportant fields
clientJoinRoomMessageSchemaroom.jointoken, optional roomId
clientLeaveRoomMessageSchemaroom.leave
clientPresenceUpdateMessageSchemapresence.updatepresence
clientBroadcastMessageSchemabroadcastevent, payload
clientYjsUpdateMessageSchemayjs.updateencoding, update, optional updateId
clientPingMessageSchemapingoptional 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

SchematypeImportant fields
serverRoomJoinedMessageSchemaroom.joinedconnection, user, room, role, ack capability
serverRoomLeftMessageSchemaroom.leftconnectionId
serverPresenceUpdatedMessageSchemapresence.updatedconnection, user, presence
serverPresenceSyncMessageSchemapresence.synccurrent room presences
serverBroadcastMessageSchemabroadcastsender, event, payload
serverYjsUpdateMessageSchemayjs.updatesender and base64 update
serverYjsSyncMessageSchemayjs.syncsequenced persisted updates
serverYjsAcknowledgedMessageSchemayjs.acksequence, updateId
serverErrorMessageSchemaerrorcode, message, retryable
serverPongMessageSchemapongoptional 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:

CodeRetryableTypical cause
unauthorizedNoInvalid, expired, or missing room token
forbiddenNoThe token's role does not allow the operation
not_foundNoThe room or project does not exist
invalid_messageNoThe frame failed schema validation
rate_limitedUsuallyToo many messages from one connection
internal_errorUsuallyThe 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.

On this page