Skip to main content
Ooneex models WebSockets as ordinary routes. On the server, @ooneex/socket lets you declare a socket endpoint with the @Route.socket(path, config) decorator and implement a controller whose index(context) runs on connect, with a typed context.channel for subscribing, publishing, and sending. On the client, @ooneex/socket-client ships a typed Socket<SendData, Response> class that auto-detects the protocol, serializes JSON, queues messages until the connection opens, and dispatches typed events. The two ends share the same request/response shapes, so a real-time feature stays type-safe across the wire.

Why WebSockets in Ooneex

  • Routes, not a separate world. A socket endpoint is declared with the same decorator family as HTTP routes (@Route.socket) and resolves a container-managed controller — the same routing, validation, roles, and middleware you already use.
  • Typed channel API. context.channel exposes subscribe, unsubscribe, publish, send, and close with the response type baked in, so broadcasts and direct replies are checked at compile time.
  • Built-in pub/sub. subscribe() joins a channel and publish() fans a message out to every subscriber — room broadcasting without a separate message bus.
  • A matching client. Socket<SendData, Response> mirrors the server’s request and response types, so the payload you send and the message you receive are the same shapes on both ends.
  • Connection-time interception. ISocketMiddleware runs the same pipeline as HTTP middleware against the socket context for auth and guards before the controller’s index runs.

How it works

A socket route is registered just like an HTTP route, but flagged as a socket and pinned to GET. When a client opens the connection, the matched controller’s index(context) runs once. From there, everything happens through context.channel.
StageWhat happens
Declaration@Route.socket(path, config) registers the controller as a socket route (isSocket: true, method GET).
ConnectionA client connects; socket middleware runs, then the controller’s index(context) executes once.
Subscribecontext.channel.subscribe() joins the channel so this connection receives published messages.
Broadcastcontext.channel.publish(response) sends a message to every subscriber of the channel.
Direct replycontext.channel.send(response) sends a message back to the connected client only.
Closecontext.channel.close(code?, reason?) ends the connection with a WebSocket close code.
Unlike an HTTP controller, a socket index returns nothing — it performs side effects on the channel. The IController contract is:
interface IController<T extends ContextConfigType = ContextConfigType> {
  index: (context: ContextType<T>) => Promise<void> | void;
}
See Routing for the decorator family and Controllers for how controllers are resolved.

Server: defining a socket controller

@Route.socket takes the path as its first argument and a config object as its second — the same signature as @Route.get, @Route.post, and the rest. Implement IController from @ooneex/socket and do your work in index using context.channel.
import { Route } from "@ooneex/routing";
import type { ContextType, IController } from "@ooneex/socket";

@Route.socket("/ws/chat", {
  name: "api.chat.connect",
  description: "Connect to the chat socket",
})
export class ChatController implements IController {
  public async index(context: ContextType): Promise<void> {
    // Join the channel so this connection receives published messages
    await context.channel.subscribe();

    // Greet the connecting client directly
    await context.channel.send(
      context.response.json({ event: "welcome", message: "Connected to chat" }),
    );
  }
}

The channel API

context.channel is the typed surface for everything a socket controller does. Every payload is built with context.response (the same response builder as HTTP controllers), so the response type flows through publish and send.
MethodReturnsDescription
subscribe()Promise<void>Join the channel so this connection receives published messages.
isSubscribed()booleanWhether this connection is currently subscribed.
unsubscribe()Promise<void>Leave the channel; published messages no longer reach this connection.
publish(response)Promise<void>Broadcast a response to every subscriber of the channel.
send(response)Promise<void>Send a response to the connected client only.
close(code?, reason?)voidClose the connection with an optional WebSocket close code and reason.
The full context type extends the HTTP controller context with this channel:
type ContextType<T extends ContextConfigType = ContextConfigType> =
  ControllerContextType<T> & {
    channel: {
      ws: ServerWebSocket<T["response"]>;
      send: (response: IResponse<T["response"]>) => Promise<void>;
      close(code?: number, reason?: string): void;
      subscribe: () => Promise<void>;
      isSubscribed(): boolean;
      unsubscribe: () => Promise<void>;
      publish: (response: IResponse<T["response"]>) => Promise<void>;
    };
  };
Because it extends the controller context, you also get context.params, context.payload, context.queries, context.user, and context.response — the same members you use in HTTP controllers.

A chat room with pub/sub

A realistic room controller uses a path parameter for the room, subscribes the connection, replies directly to the joiner, and publishes a join event to everyone else in the room. Type the context for end-to-end safety.
import { Route } from "@ooneex/routing";
import { type } from "@ooneex/validation";
import type { ContextConfigType, ContextType, IController } from "@ooneex/socket";

interface ChatRoomConfig extends ContextConfigType {
  params: { roomId: string };
  payload: { message: string };
  response: { event: string; roomId: string; message?: string; userId?: string };
}

@Route.socket("/ws/rooms/:roomId", {
  name: "api.rooms.join",
  description: "Join a chat room and broadcast messages",
  params: { roomId: type("string") },
  payload: type({ message: "string" }),
})
export class ChatRoomController implements IController<ChatRoomConfig> {
  public async index(context: ContextType<ChatRoomConfig>): Promise<void> {
    const { roomId } = context.params;
    const { message } = context.payload;

    // Join the room channel
    await context.channel.subscribe();

    // Confirm the join to this client only
    await context.channel.send(
      context.response.json({ event: "joined", roomId }),
    );

    // Broadcast the message to everyone subscribed to the room
    await context.channel.publish(
      context.response.json({
        event: "message",
        roomId,
        message,
        userId: context.user?.id,
      }),
    );
  }
}
To leave a room or end the connection, unsubscribe and optionally close with a WebSocket close code:
if (context.channel.isSubscribed()) {
  await context.channel.unsubscribe();
}

context.channel.close(1000, "Left the room");

Socket middleware

Connection-time interception uses ISocketMiddleware, which mirrors the HTTP IMiddleware shape against the socket context — same decorator, same handler(context) contract, same short-circuiting. Use it for auth and guards before index runs.
import type { ContextType } from "@ooneex/socket";
import { decorator, type ISocketMiddleware } from "@ooneex/middleware";

@decorator.middleware()
export class SocketAuthMiddleware implements ISocketMiddleware {
  public async handler(context: ContextType): Promise<ContextType> {
    return context;
  }
}
See Middleware for the full pipeline, ordering, and short-circuit rules.

Client: connecting with @ooneex/socket-client

Socket<SendData, Response> is a thin, typed wrapper over the browser WebSocket. The constructor takes a single URL and auto-detects the protocol: http:// becomes ws://, https:// becomes wss://, and a bare host (no scheme) is upgraded to wss://. Outgoing data is JSON-serialized for you, and incoming messages are parsed back into the typed Response.
import { Socket } from "@ooneex/socket-client";

// http:// -> ws://, https:// -> wss://, bare host -> wss://
const socket = new Socket("https://api.example.com/ws/chat");

socket.onOpen(() => console.log("connected"));
socket.onMessage((response) => console.log("received", response));
socket.onClose((event) => console.log("closed", event.code, event.reason));
socket.onError((event, response) => console.error("error", event, response));

Client methods

The two generic parameters tie the client to the server: SendData (extending RequestDataType) is what you send, and Response is the message shape you receive (wrapped as ResponseDataType<Response>).
MethodReturnsDescription
send(data)voidJSON-serialize and send data; queued if the connection is not yet open.
onMessage(handler)voidRegister a handler for incoming, successfully-parsed messages.
onOpen(handler)voidRegister a handler that runs when the connection opens.
onClose(handler)voidRegister a handler for connection close, receiving the CloseEvent.
onError(handler)voidRegister a handler for errors; also called for messages flagged unsuccessful.
close(code?, reason?)voidClose the connection with an optional code and reason.
A few behaviors are worth knowing:
  • Message queuing. Calling send before the socket is open pushes the message onto an internal queue; the queue is flushed in order as soon as onopen fires, so you never have to wait for the connection by hand.
  • JSON in and out. send runs JSON.stringify on your data, and incoming frames are JSON.parsed into ResponseDataType<Response> before reaching onMessage.
  • Success and done flags. A parsed message with success true is delivered to onMessage; an unsuccessful one is routed to the onError handler with the parsed body. If a message carries done, the client closes the connection.
  • Locale support. RequestDataType includes an optional lang field, so you can attach locale information to any message you send.

Typed client usage

Mirror the server’s request and response shapes for a fully typed exchange. The payload you send matches the controller’s expected payload, and the message you receive matches the controller’s response.
import { Socket, type RequestDataType } from "@ooneex/socket-client";

interface ChatRequest extends RequestDataType {
  payload: { message: string };
  queries?: { roomId: string };
}

interface ChatResponse {
  event: string;
  roomId: string;
  message?: string;
  userId?: string;
}

const socket = new Socket<ChatRequest, ChatResponse>(
  "https://api.example.com/ws/rooms/general",
);

socket.onMessage((response) => {
  // response is typed as ResponseDataType<ChatResponse>
  if (response.data.event === "message") {
    console.log(`${response.data.userId}: ${response.data.message}`);
  }
});

// Safe to call immediately — this is queued until the socket opens
socket.send({
  payload: { message: "Hello, room!" },
  queries: { roomId: "general" },
  lang: { locale: "en" },
});

// Later, close cleanly
socket.close(1000, "User left");

Best practices

  • Path first. Call @Route.socket(path, config) with the path as the first argument and the config object second — the config never contains path.
  • Subscribe before you publish. Join the channel with subscribe() so the connection receives the broadcasts it triggers; check isSubscribed() before unsubscribing.
  • send vs publish. Use send to reply to the connected client only; use publish to fan a message out to every subscriber of the channel.
  • Type both ends. Define a ContextConfigType on the server and matching SendData/Response generics on the client so payloads and messages stay checked across the wire.
  • Guard at the edge. Put auth and rate limits in ISocketMiddleware so unauthorized connections are rejected before index runs.
  • Close with intent. Pass a standard WebSocket close code (e.g. 1000 for normal closure, 1008 for policy violations) and a human-readable reason so clients can react.
  • Let the client queue. Call send as soon as you have data — the client flushes queued messages on open, so there is no need to wait for onOpen to start sending.