Skip to main content
The @websocket decorator defines a WebSocket API that opens a bidirectional message channel between client and server. Unlike @stream (SSE-only), it can also handle events sent by the client (inEvents) and broadcast messages to other connections in the same namespace/room.

Basic Usage

import { BaseFrameClass, Sonamu, websocket, type WebSocketContext } from "sonamu";
import { z } from "zod";

const ChatOutEvents = z.object({
  newMessage: z.object({
    id: z.number(),
    content: z.string(),
    userId: z.string(),
  }),
  typingUsers: z.array(z.object({ id: z.string(), name: z.string() })),
});

const ChatInEvents = z.object({
  send: z.object({ content: z.string() }),
  typing: z.object({ active: z.boolean() }),
});

class ChatFrameClass extends BaseFrameClass {
  @websocket({
    namespace: "chat",
    heartbeat: 30_000,
    guards: ["user"],
    outEvents: ChatOutEvents,
    inEvents: ChatInEvents,
  })
  async subscribeChat(
    ctx: WebSocketContext<typeof ChatOutEvents.shape, typeof ChatInEvents.shape>,
  ): Promise<void> {
    const user = ctx.user;
    if (!user) throw new Error("unauthenticated");

    ctx.ws.join("global");
    ctx.ws.setUserId(user.id);

    ctx.ws.onMessage("send", (data) => {
      Sonamu.websocketRuntime.publishToRoom(
        "global",
        "newMessage",
        { id: Date.now(), content: data.content, userId: user.id },
        "chat",
      );
    });

    ctx.ws.onClose(() => {
      ctx.ws.leave("global");
    });

    await ctx.ws.waitForClose();
  }
}

Options

outEvents

Defines server → client event types using a Zod schema. Required option
import { z } from "zod";

const OutEvents = z.object({
  ready: z.object({ history: z.array(z.string()) }),
  newMessage: z.object({ id: z.number(), content: z.string() }),
});

inEvents

Defines client → server event types using a Zod schema. Required option
const InEvents = z.object({
  send: z.object({ content: z.string() }),
  setPeriod: z.object({ period: z.enum(["7", "30", "all"]) }),
});
Inbound messages are validated against inEvents via the envelope contract. Events not declared in the schema, or payloads that fail validation, are not delivered to handlers and are treated as policy violations.

path

WebSocket endpoint path. Default: /{modelName}/{methodName} (camelCase)
@websocket({
  outEvents,
  inEvents,
  path: "/realtime/chat",
})
async subscribeChat(ctx: WebSocketContext) {}

namespace

A logical group that scopes routing/broadcast inside the same server. Sonamu.websocketRuntime.publishToRoom(roomId, event, data, namespace) only delivers to connections in the same namespace. Default: "default"
@websocket({ outEvents, inEvents, namespace: "chat" })

heartbeat

Heartbeat (ping) interval in milliseconds. Default: 30000 (30 seconds)
@websocket({ outEvents, inEvents, heartbeat: 15_000 })

guards

Restricts who can connect.
type GuardKey = "query" | "admin" | "user";
@websocket({ outEvents, inEvents, guards: ["user"] })
Guard failures are rejected during the handshake and close with 1008 Policy Violation.

description

Adds a description for the WebSocket API.
@websocket({
  outEvents,
  inEvents,
  description: "Subscribes to the global chat channel.",
})

resourceName

Specifies the resource name for the generated service file.
@websocket({ outEvents, inEvents, resourceName: "Chat" })

WebSocketContext

A @websocket-decorated method receives WebSocketContext<TOut, TIn> as its last argument.
type WebSocketContext<TOut, TIn> = BaseContext & {
  transport: "ws";
  ws: WebSocketConnection<TOut, TIn>;
};

ctx.ws.publish

Sends an event to this connection only.
ctx.ws.publish("ready", { history: ["hello", "world"] });

ctx.ws.onMessage

Handles events sent by the client.
ctx.ws.onMessage("send", async (data) => {
  // data is validated against inEvents.send
});

ctx.ws.onClose

Runs cleanup when the connection closes.
ctx.ws.onClose(() => {
  ctx.ws.leave("global");
});

ctx.ws.waitForClose

Keeps the handler alive until the connection closes. WebSocket methods typically await this promise to wrap up.
await ctx.ws.waitForClose();

ctx.ws.join / ctx.ws.leave

Joins or leaves a room, which is the broadcast unit.
ctx.ws.join("room-1");
ctx.ws.leave("room-1");

ctx.ws.setUserId / ctx.ws.clearUserId

Attaches a user identifier to the connection so it can be targeted by publishToUser.
ctx.ws.setUserId(user.id);

Broadcast

From outside the handler you can also push messages to other connections via Sonamu.websocketRuntime.
import { Sonamu } from "sonamu";

Sonamu.websocketRuntime.publishToRoom(
  "global",
  "newMessage",
  { id: 1, content: "hello" },
  "chat", // namespace
);

Sonamu.websocketRuntime.publishToUser(
  userId,
  "notify",
  { kind: "mention" },
  "chat",
);

Combining With Other Decorators

@websocket cannot share a method with @api, @stream, or @upload. If you want to expose the same data via both a WebSocket subscription and a plain HTTP endpoint, split them into two methods — one with @api, the other with @websocket.
class DashboardFrameClass extends BaseFrameClass {
  @api({ httpMethod: "GET" })
  async getRecentActivity(period: ActivityPeriod = "7"): Promise<ActivityGroup[]> {
    // ...
  }

  @websocket({ outEvents, inEvents })
  async getRecentActivityStream(
    initialPeriod: ActivityPeriod = "7",
    ctx: WebSocketContext<typeof outEvents.shape, typeof inEvents.shape>,
  ): Promise<void> {
    // ...
  }
}

Constraints

1. Cannot be combined with @api / @stream / @upload

@websocket decorator can only be used once on Chat.subscribe.
You can use only one of @api, @stream, @websocket, or @upload decorator on the same method.

2. httpMethod is always GET

@websocket automatically sets httpMethod: "GET" because the WebSocket upgrade starts from a GET request.

3. Watch out for context providers that depend on createSSE / reply

In the WebSocket path, SSE helpers and FastifyReply access are meaningless and raise an error immediately. If your context setup depends on them, define a dedicated websocketContextProvider.

Client Usage (Web)

For each @websocket method, Sonamu auto-generates a useWebSocketChannel hook.
import { ChatService } from "@/services/ChatService";

function ChatRoom() {
  const channel = ChatService.useSubscribeChat(
    {},
    {
      newMessage: (msg) => {
        // outEvents.newMessage data
      },
      typingUsers: (users) => {
        // outEvents.typingUsers data
      },
    },
  );

  return (
    <div>
      <div>{channel.isConnected ? "connected" : "disconnected"}</div>
      <button onClick={() => channel.send("send", { content: "hi" })}>send</button>
    </div>
  );
}
State returned from useWebSocketChannel:
type WebSocketChannelState<TSend> = {
  isConnected: boolean;
  error: string | null;
  retryCount: number;
  readyState: number;
  send<K extends keyof TSend>(event: K, data: TSend[K]): void;
  close(code?: number, reason?: string): void;
};
Options:
type WebSocketChannelOptions = {
  enabled?: boolean;
  retry?: number;
  retryInterval?: number;
  protocols?: string | string[];
  traceProvider?: () => string | undefined;
};

Logging

The @websocket decorator emits a debug log on each invocation.
[DEBUG] websocket: ChatFrame.subscribeChat

Compared to @stream

Item@stream (SSE)@websocket
DirectionServer → client (one-way)Bidirectional
Client eventsNot supportedValidated against inEvents
BroadcastPer-SSE publish onlyroom / userId routing built-in
HTTP methodGET (auto)GET (auto, upgrade)
Generated clienteventSource helpersuseWebSocketChannel hook

Next Steps

@stream

When one-way SSE streaming is enough

@api

Build a standard REST endpoint

@transactional

Wrap mutations in a transaction

@cache

Improve performance with caching