Skip to content
rest-rpc
Esc
navigateopen⌘Jpreview
On this page

WebSockets

Declare typed WebSocket routes in the contract.

WebSocket routes use the same contract tree as HTTP routes.

They must use method: "GET" and options: { mode: "websocket" }.

watch: {
	method: "GET",
	path: "/todos/:id/watch",
	options: {
		mode: "websocket",
	},
	request: {
		params: {
			id: z.string(),
		},
	},
	messages: {
		client: clientMessageSchema,
		server: serverMessageSchema,
	},
}

Contract

export const api = router({
	todos: {
		watch: {
			method: "GET",
			path: "/todos/:id/watch",
			options: {
				mode: "websocket",
			},
			request: {
				params: {
					id: z.string(),
				},
			},
			messages: {
				client: z.object({
					type: z.literal("ping"),
				}),
				server: z.discriminatedUnion("type", [
					z.object({
						type: z.literal("changed"),
						id: z.string(),
						completed: z.boolean(),
					}),
					z.object({
						type: z.literal("pong"),
					}),
				]),
			},
		},
	},
});

Server Handler

Server handlers receive typed socket context.

const routes = router(api, {
	todos: {
		watch({ id, context }) {
			context.socket.send({
				type: "changed",
				id,
				completed: false,
			});

			context.socket.onMessage((message) => {
				if (message.type === "ping") {
					context.socket.send({
						type: "pong",
					});
				}
			});
		},
	},
});

Incoming messages are parsed and validated before delivery. Invalid incoming messages close the connection.

Outgoing server messages are validated before being sent. They are sent as JSON, so schema output must still make sense as a JSON message.

Client

WebSocket client routes expose openConnection().

const socket = client.todos.watch.openConnection({
	id: "todo_1",
});
socket.onMessage((message) => {
	if (message.type === "changed") {
		console.log(message.completed);
	}
});

socket.send({
	type: "ping",
});

By default, incoming server messages are JSON-parsed but trusted by the client. If the client enables validateResponses, incoming server messages are also validated on the client. See HTTP and Schema I/O gotchas before using that option with transformed server message schemas.

Registration

Adapters register WebSocket routes with explicit WebSocket options.

Install ws when using WebSocket routes.

pnpm add ws

Express uses a Node HTTP server for the upgrade event and a ws WebSocket server to accept sockets. Create the Node server from the Express app, then pass both servers in webSocket.

beforeUpgrade runs after request validation and before the socket is accepted. Return an upgrade rejection to deny the connection, or read and attach data to context.req for the WebSocket handler.

import { createServer } from "node:http";
import express from "express";
import { WebSocketServer } from "ws";
import { registerRoutes } from "@rest-rpc/express";

const expressApp = express();
const nodeServer = createServer(expressApp);
const webSocketServer = new WebSocketServer({ noServer: true });

registerRoutes(expressApp, routes, {
	webSocket: {
		server: nodeServer,
		webSocketServer,
		beforeUpgrade({ context, route, request }) {
			context.req;
			route;
			request;
			return undefined;
		},
	},
});

nodeServer.listen(3000);

Hono gets upgradeWebSocket from the Hono runtime adapter. Create the Hono app normally, import upgradeWebSocket from your runtime, then pass both to registerRoutes.

beforeUpgrade runs after request validation and before the socket is accepted. Return an upgrade rejection to deny the connection, or read and attach data to context.c for the WebSocket handler.

import { Hono } from "hono";
import { upgradeWebSocket } from "hono/cloudflare-workers";
import { registerRoutes } from "@rest-rpc/hono";

const honoApp = new Hono();

registerRoutes(honoApp, routes, {
	webSocket: {
		upgradeWebSocket,
		beforeUpgrade({ context, route, request }) {
			context.c;
			route;
			request;
			return undefined;
		},
	},
});

Install @fastify/websocket when using WebSocket routes.

pnpm add @fastify/websocket

Fastify requires @fastify/websocket. Create the Fastify app, register the plugin before registering routes, then pass WebSocket options to registerRoutes.

beforeUpgrade runs after request validation and before the socket is accepted. Return an upgrade rejection to deny the connection, or read and attach data to context.req for the WebSocket handler.

import websocket from "@fastify/websocket";
import Fastify from "fastify";
import { registerRoutes } from "@rest-rpc/fastify";

const fastifyApp = Fastify();

await fastifyApp.register(websocket);

registerRoutes(fastifyApp, routes, {
	webSocket: {
		beforeUpgrade({ context, route, request }) {
			context.req;
			route;
			request;
			return undefined;
		},
	},
});

Was this page helpful?