WebSocket API

Client example

A complete TypeScript client for the WebSocket API, to copy into your app or read alongside the how-tos.

A zero-dependency reference client for the Predict WebSocket API, with heartbeat handling, reconnect notices, reconnection with backoff, subscription tracking, and typed payloads.

/**
 * A zero-dependency client for the Predict WebSocket API.
 * Designed to be used in the browser.
 */

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

function neOf<T>(item: T): NonEmptyArray<T> {
  return [item] as NonEmptyArray<T>;
}

function isNonEmpty<T>(items: T[]): items is NonEmptyArray<T> {
  return items.length > 0;
}

/*
 * Realtime Client
 */

export class RealtimeClient {
  constructor(
    private ws: WebSocket,
    private readonly options: {
      maxConnAttempts: number;
      maxRetryInterval: number;
    },
  ) {
    this.bindAll();
  }

  /** Topic of each in-flight subscribe or unsubscribe request, cleared on its response. */
  private topicSubReqIdMap: Map<number, TopicName> = new Map();

  /** Active topics this connection is subscribed to. */
  private subscriptions: Map<TopicName, NonEmptyArray<EventCallback>> = new Map();

  private connectionAttempts = 0;
  private requestId = 0;
  /** Set by close(), so the client doesn't reconnect after a close it asked for. */
  private closedByUser = false;
  /** Set by a reconnect notice, so the next connection opens at once. */
  private reconnectNow = false;

  private bindAll(): void {
    this.ws.onerror = this.onError;
    this.ws.onmessage = this.onMessage;
    this.ws.onclose = this.onClose;
    this.ws.onopen = this.onOpen;
  }

  private async reconnect() {
    // a reconnect notice moves to a new connection at once, and isn't a failed attempt.
    if (this.reconnectNow) {
      this.reconnectNow = false;
      return this.open();
    }

    const attempt = this.connectionAttempts++;
    if (attempt < this.options.maxConnAttempts) {
      // reconnect at once the first time. while reconnecting keeps failing, wait
      // 1, 2, 4 and 8 seconds and so on, up to maxRetryInterval. up to half a
      // second of jitter keeps clients from all reconnecting at the same moment.
      const wait =
        attempt === 0 ? 0 : Math.min(1000 * 2 ** (attempt - 1), this.options.maxRetryInterval);
      await sleep(wait + Math.random() * 500);
      this.open();
    } else {
      const allHandlers = Array.from(this.subscriptions.values()).flat();
      for (const handler of allHandlers) {
        handler({
          err: { code: 'ws_disconnected', message: 'Max connection attempts reached' },
        });
      }
    }
  }

  /** Opens a new connection to the same URL, unless close() was called. */
  private open() {
    if (this.closedByUser) return;
    this.ws = new WebSocket(
      this.ws.url,
      this.ws.protocol !== '' ? this.ws.protocol : undefined,
    );
    this.bindAll();
  }

  private onOpen = () => {
    this.connectionAttempts = 0;
    // re-subscribe every active topic after a reconnect.
    for (const topic of this.subscriptions.keys()) {
      this.subUnsub('subscribe', topic);
    }
  };

  private onClose = () => {
    // reconnect after every close the client didn't ask for, clean or not. the
    // server's own closes, such as 1001 when it shuts down, are clean ones.
    if (!this.closedByUser) {
      return this.reconnect();
    }
  };

  private onMessage = (event: MessageEvent) => {
    const parsed = JSON.parse(event.data as unknown as string) as Response;

    if (parsed.type === 'M') {
      const topic = parsed.topic;

      if (topic === 'heartbeat') {
        // echo the server timestamp back exactly.
        this.send({ method: 'heartbeat', data: parsed.data });
      } else if (parsed.topic === 'system') {
        if (parsed.data.event === 'reconnect') {
          // the server is shutting down, so move to a new connection right away.
          this.reconnectNow = true;
          this.ws.close();
        }
      } else {
        const handlers = this.subscriptions.get(parsed.topic);
        for (const handler of handlers || []) {
          handler({ data: parsed.data });
        }
      }
    } else if (parsed.type === 'R') {
      const requestIdTopic = this.topicSubReqIdMap.get(parsed.requestId);

      if (requestIdTopic) {
        if (!parsed.success) {
          const handlers = this.subscriptions.get(requestIdTopic);
          if (handlers) {
            for (const handler of handlers) {
              handler({ err: parsed.error });
            }
          }
          this.subscriptions.delete(requestIdTopic);
        }
        this.topicSubReqIdMap.delete(parsed.requestId);
      } else {
        console.warn(`Unknown response received`, JSON.stringify(parsed, null, 2));
      }
    }
  };

  private onError = (event: Event) => {
    const allHandlers = Array.from(this.subscriptions.values()).flat();
    for (const handler of allHandlers) {
      handler({ err: { code: 'ws_error_terminated', message: 'Websocket error' } });
    }
    console.error('RealtimeClientSocketError', event);
  };

  private send(data: Requests) {
    if (this.ws.readyState === this.ws.OPEN) {
      if (data.method !== 'heartbeat') {
        this.topicSubReqIdMap.set(data.requestId, data.params[0]);
      }
      return this.ws.send(JSON.stringify(data));
    } else {
      console.warn(`Not connected to WS. Ignoring.`);
    }
  }

  private subUnsub(method: 'subscribe' | 'unsubscribe', topic: string) {
    this.send({ requestId: this.requestId++, method, params: neOf(topic) });
  }

  private getTopicStringFor(channel: Channel): string {
    switch (channel.name) {
      case 'predictOrderbook':
        return `predictOrderbook/${channel.marketId}`;
      case 'predictTradingStatus':
        return `predictTradingStatus/${channel.marketId}`;
      case 'predictMarketStatus':
        return `predictMarketStatus/${channel.marketId}`;
      case 'predictMarketChanged':
        return `predictMarketChanged/${channel.marketId}`;
      case 'predictCategoryChanged':
        return `predictCategoryChanged/${channel.categoryId}`;
      case 'predictWalletEvents':
        return `predictWalletEvents/${channel.jwt}`;
    }
  }

  subscribe(channel: Channel, callback: EventCallback): { unsubscribe: () => void } {
    const topicName = this.getTopicStringFor(channel);
    const existing = this.subscriptions.get(topicName);

    if (existing) {
      existing.push(callback);
    } else {
      this.subscriptions.set(topicName, [callback]);
      this.subUnsub('subscribe', topicName);
    }

    return {
      unsubscribe: (): void => {
        const item = this.subscriptions.get(topicName);
        if (!item) {
          throw new Error('InconsistentState: No subscriptions for this topic');
        }
        const cbRemoved = item.filter((x) => x !== callback);
        if (isNonEmpty(cbRemoved)) {
          this.subscriptions.set(topicName, cbRemoved);
        } else {
          this.subscriptions.delete(topicName);
          this.subUnsub('unsubscribe', topicName);
        }
      },
    };
  }

  close(): void {
    this.closedByUser = true;
    this.ws.close();
  }
}

/*
 * Types
 */

export type NonEmptyArray<T> = [T, ...T[]];
export type Pretty<T> = { [K in keyof T]: T[K] } extends infer U ? U : never;

/* Payloads */

export type MarketTradingStatusUpdate = {
  kind: 'tradingStatusUpdate';
  tsMs: number;
  marketId: number;
  tradingStatus: 'OPEN' | 'MATCHING_NOT_ENABLED' | 'CANCEL_ONLY' | 'CLOSED';
};

export type MarketStatusOutcomeEntry = {
  label: string;
  price: string;
  indexSet: number;
  onChainId?: string;
};

export type MarketStatusUpdatePayload = {
  kind: 'marketStatusUpdate';
  tsMs: number;
  marketId: number;
  status: string;
  marketOutcomes?: MarketStatusOutcomeEntry[];
};

export type DepthLevel = [number, number];

export type OrderbookLastSettled = {
  id: string;
  price: string;
  kind: string;
  marketId: number;
  side: 'Ask' | 'Bid';
  outcome: 'Yes' | 'No';
};

export type PredictOrderbookUpdate = {
  version: 1;
  marketId: number;
  updateTimestampMs: number;
  orderCount: number;
  asks: DepthLevel[];
  bids: DepthLevel[];
  lastOrderSettled: OrderbookLastSettled | null;
  settlementsPending: { asks: DepthLevel[]; bids: DepthLevel[] };
};

export type MarketChangedUpdate = {
  kind: 'marketChanged';
  patchKind: string | null;
  tsMs: number;
  marketId: number;
  patch?: Record<string, unknown>;
};

export type CategoryChangedUpdate = {
  kind: 'categoryChanged';
  patchKind: string | null;
  tsMs: number;
  categoryId: number;
  slug: string;
  patch?: Record<string, unknown>;
};

/** A change with no detail. Refetch the category. */
export type CategoryChangedNotice = {
  categoryId: number;
  tsMs: number;
};

export type SystemEvent = {
  event: string;
  reason: string;
  graceMs: number;
};

/* Wallet events */

type BaseEvent = {
  orderId: string;
  orderHash: string;
  timestamp: number;
  walletAddress?: string;
  details: {
    marketId: number;
    outcomeIndex: number;
    marketQuestion: string;
    outcome: 'YES' | 'NO';
    outcomeName: string;
    quoteType: 'ASK' | 'BID';
    quantity: string;
    quantityFilled: string;
    price: string;
    value: string;
    valueFilled: string;
    strategyType: 'MARKET' | 'LIMIT';
    categorySlug: string;
  };
};

type Fill = {
  executedPriceWei: string;
  executedSizeWei: string;
  executedValueWei: string;
};

type OrderAccepted = BaseEvent & { type: 'orderAccepted' };
type OrderNotAccepted = BaseEvent & {
  type: 'orderNotAccepted';
  reason: 'noMarketMatch' | 'rejectedDuplicate' | 'rejectedPostOnly';
  settlementId?: string | null;
};
type OrderMarketMatchPlan = BaseEvent & {
  type: 'orderMarketMatchPlan';
  amount: string;
  amountFilled: string;
  plannedFill: string;
  remainderCancelled: string;
  settlementIds: string[];
};
type OrderExpired = BaseEvent & { type: 'orderExpired' };
type OrderCancelled = BaseEvent & {
  type: 'orderCancelled';
  reason?: 'selfTradePrevention';
};
type OrderTransactionSubmitted = BaseEvent & {
  type: 'orderTransactionSubmitted';
  kind: 'SALE' | 'PURCHASE';
  isMaker: boolean;
  settlementId: string;
  fill: Fill;
};
type OrderTransactionSuccess = BaseEvent & {
  type: 'orderTransactionSuccess';
  settlementId: string;
  fill: Fill;
  isMaker?: boolean;
  fee?: { amountWei: string; type: 'COLLATERAL' | 'SHARES' };
};
type OrderTransactionFailed = BaseEvent & {
  type: 'orderTransactionFailed';
  settlementId: string;
  fill: Fill;
};

export type PredictWalletEvents =
  | OrderAccepted
  | OrderNotAccepted
  | OrderMarketMatchPlan
  | OrderExpired
  | OrderCancelled
  | OrderTransactionSubmitted
  | OrderTransactionSuccess
  | OrderTransactionFailed;

/* Channels */

export interface PredictOrderbookChannel { name: 'predictOrderbook'; marketId: number }
export interface PredictTradingStatusChannel { name: 'predictTradingStatus'; marketId: number }
export interface PredictMarketStatusChannel { name: 'predictMarketStatus'; marketId: number }
export interface PredictMarketChangedChannel { name: 'predictMarketChanged'; marketId: number }
export interface PredictCategoryChangedChannel { name: 'predictCategoryChanged'; categoryId: number }
export interface PredictWalletEventsChannel { name: 'predictWalletEvents'; jwt: string }

export type Channel = Pretty<
  | PredictOrderbookChannel
  | PredictTradingStatusChannel
  | PredictMarketStatusChannel
  | PredictMarketChangedChannel
  | PredictCategoryChangedChannel
  | PredictWalletEventsChannel
>;

/* Requests */

type WithRequestId<T> = T & { requestId: number };
export type SubscribeRequest = WithRequestId<{ method: 'subscribe'; params: NonEmptyArray<string> }>;
export type UnsubscribeRequest = WithRequestId<{ method: 'unsubscribe'; params: NonEmptyArray<string> }>;
export type HeartbeatRequest = { method: 'heartbeat'; data: unknown };
export type Requests = Pretty<HeartbeatRequest | SubscribeRequest | UnsubscribeRequest>;

/* Errors */

type InvalidJson = { code: 'invalid_json'; message?: string };
export type InvalidTopic = { code: 'invalid_topic'; message?: string };
export type InvalidParams = { code: 'invalid_params'; message?: string };
export type InvalidCredentials = { code: 'invalid_credentials'; message?: string };
export type InternalServerError = { code: 'internal_server_error'; message?: string };

export type ResponseError = Pretty<
  InvalidJson | InvalidTopic | InvalidParams | InvalidCredentials | InternalServerError
>;

/* Responses */

export type RequestResponse = {
  type: 'R';
  requestId: number;
} & ({ success: true } | { success: false; error: ResponseError });

export type MessageResponse<Topic extends string, Data> = {
  type: 'M';
  topic: Topic;
  data: Data;
};

export type MessageResponses =
  | MessageResponse<'heartbeat', number>
  | MessageResponse<`predictOrderbook${string}`, PredictOrderbookUpdate>
  | MessageResponse<`predictTradingStatus${string}`, MarketTradingStatusUpdate>
  | MessageResponse<`predictMarketStatus${string}`, MarketStatusUpdatePayload>
  | MessageResponse<`predictMarketChanged${string}`, MarketChangedUpdate>
  | MessageResponse<`predictCategoryChanged${string}`, CategoryChangedUpdate | CategoryChangedNotice>
  | MessageResponse<`predictWalletEvents${string}`, PredictWalletEvents>;

export type Response = Pretty<
  MessageResponses | MessageResponse<'system', SystemEvent> | RequestResponse
>;

/* Internal */

export type WSError =
  | ResponseError
  | { code: 'ws_disconnected'; message?: string }
  | { code: 'ws_error_terminated'; message?: string };

export type TopicName = string;

export type EventCallback = (
  arg: { err?: null; data: MessageResponses['data'] } | { err: WSError; data?: null },
) => void;