How WebSocket Decorators Enable Real-Time Updates in Plane
Plane uses a metadata-driven decorator system where the @WebSocket decorator marks class methods as WebSocket handlers, allowing the registerController function to automatically discover and register them with an Express-compatible router for bidirectional real-time communication.
Plane is an open-source project management platform that powers collaborative workspaces with instant synchronization across clients. The application achieves this real-time capability through a lightweight decorator-based architecture defined in the @plane/decorators package, which transforms ordinary TypeScript class methods into WebSocket endpoints without mixing low-level socket handling throughout the codebase.
The @WebSocket Decorator Architecture
The foundation of Plane's real-time system resides in packages/decorators/src/websocket.ts. The @WebSocket decorator is a method decorator that attaches two critical pieces of metadata to the target method using the Reflect API:
method = "ws"– Signals to the registration logic that this handler operates over WebSocket protocol rather than HTTP.route– Specifies the URL path segment that clients connect to.
This metadata attachment allows the system to distinguish WebSocket endpoints from traditional REST endpoints at runtime without requiring separate controller classes.
Controller Registration and Discovery
When a controller class is registered via registerController in packages/decorators/src/controller.ts, the system performs a metadata scan to identify WebSocket-capable methods.
Metadata Scanning Logic
During registration (lines 31‑36), the helper iterates over the controller's prototype methods and checks for the method metadata key. If a method returns "ws", the controller is flagged as a WebSocket controller:
// Simplified scanning logic from controller.ts
Object.getOwnPropertyNames(Controller.prototype).forEach(name => {
if (name === 'constructor') return;
const methodType = Reflect.getMetadata('method', instance, name);
if (methodType === 'ws') {
// Mark for WebSocket registration
}
});
registerWebSocketController Implementation
The registerWebSocketController function (lines 79‑107) handles the actual route binding. It retrieves the controller's baseRoute metadata, concatenates it with the method-level route, and invokes router.ws() to register the handler with the Express-compatible router:
function registerWebSocketController(router, Controller, instance) {
const base = Reflect.getMetadata('baseRoute', Controller);
Object.getOwnPropertyNames(Controller.prototype).forEach(name => {
if (name === 'constructor') return;
const method = Reflect.getMetadata('method', instance, name);
const route = Reflect.getMetadata('route', instance, name);
if (method === 'ws' && route) {
router.ws(`${base}${route}`, (ws, req) => {
instance[name].call(instance, ws, req);
});
}
});
}
This registration pattern ensures that each new WebSocket connection instantiates the handler with access to both the live WebSocket object and the original HTTP request, enabling parameter extraction from URLs (e.g., req.params.id).
Real-Time Capabilities and Error Handling
Once registered, WebSocket decorator methods facilitate real-time updates through bidirectional communication channels. The handler receives the raw WebSocket connection, allowing developers to:
- Push updates instantly using
ws.send(JSON.stringify(data)) - Listen for client messages via
ws.on('message', callback) - Access HTTP request context including authentication headers and URL parameters
Error Handling and Connection Safety
The decorator system implements robust error isolation (lines 99‑103). Any exception thrown within a WebSocket method is caught, logged, and results in the socket closing with a 1011 status code (internal error):
// Error handling pattern from controller.ts lines 99-103
try {
await instance[methodName].call(instance, ws, req);
} catch (error) {
console.error(`WebSocket error in ${methodName}:`, error);
ws.close(1011, 'Internal server error');
}
This ensures that server-side failures do not leave connections hanging and clients receive explicit notification of terminal errors.
Mixed REST and WebSocket Controllers
A key architectural advantage of Plane's WebSocket decorators is the ability to colocate real-time and traditional HTTP endpoints within the same controller class. Because @WebSocket merely records metadata, a controller can simultaneously expose REST methods (handled by packages/decorators/src/rest.ts) and WebSocket methods.
Dependency injection remains consistent across both protocol types. The registerController function creates a single controller instance (line 29) and reuses it for both REST and WebSocket registration:
// Instance creation line 29 in controller.ts
const instance = new Controller();
This singleton-per-registration pattern ensures that shared service dependencies—such as database connections or message brokers—maintain consistent state regardless of whether a client accesses the controller via HTTP polling or WebSocket subscription.
Practical Implementation Example
Developers implement real-time features by applying the @WebSocket decorator alongside @BaseRoute and @Controller. The following example demonstrates a room subscription controller that pushes updates to connected clients:
import { WebSocket, Controller, BaseRoute } from '@plane/decorators';
@BaseRoute('/api/rooms')
export class RoomController {
@WebSocket('/:id')
async join(ws: WebSocket, req: Request) {
const roomId = req.params.id;
// Confirm subscription to client
ws.send(JSON.stringify({ message: `Joined room ${roomId}` }));
// Listen for future data changes and broadcast
ws.on('message', (data) => {
// Handle incoming client messages
});
}
}
When the server initializes and calls registerController(router, RoomController), the system discovers the join method via its metadata, registers the route /api/rooms/:id via router.ws, and executes the method for each new socket connection. Clients connecting to wss://api/rooms/123 immediately enter the real-time communication channel.
Summary
- Metadata-driven discovery: The
@WebSocketdecorator inpackages/decorators/src/websocket.tsstoresmethod: "ws"and route metadata on class methods. - Automatic registration:
registerControllerscans for WebSocket metadata (lines 31‑36) and delegates toregisterWebSocketController(lines 79‑107) to bind routes. - Protocol duality: Single controller classes can host both REST and WebSocket endpoints with shared dependency injection (line 29).
- Production safety: Built-in error handling closes connections with 1011 status codes when exceptions occur (lines 99‑103).
- Express integration: The system leverages
router.ws()from thewslibrary to register handlers compatible with existing Express middleware stacks.
Frequently Asked Questions
How does the @WebSocket decorator differ from REST decorators in Plane?
While REST decorators (defined in packages/decorators/src/rest.ts) store HTTP-specific metadata like GET, POST, or PUT, the @WebSocket decorator stores method: "ws" to indicate bidirectional persistent connections. Both use the same Reflect.defineMetadata mechanism, allowing registerController to scan and categorize endpoints uniformly, but WebSocket handlers receive a WebSocket object and Request object rather than standard HTTP request/response pairs.
Can a single controller handle both HTTP and WebSocket connections simultaneously?
Yes. Because decorators only attach metadata without altering method signatures, a controller class can mix @Get, @Post, and @WebSocket decorators on different methods. The registerController function creates one instance of the controller (line 29) and processes all metadata, registering REST routes via the standard Express router and WebSocket routes via router.ws(). This colocation ensures shared service dependencies remain consistent across both communication protocols.
What happens when an error occurs inside a WebSocket decorator method?
The registerWebSocketController function wraps method execution in a try-catch block (lines 99‑103). If an exception occurs, the error is logged to the console, and the WebSocket connection closes with a 1011 status code indicating an internal server error. This prevents uncaught exceptions from crashing the server while providing clients with a clean disconnect signal.
How does dependency injection work with WebSocket controllers?
Plane's decorator system instantiates the controller class once during registration (line 29 in packages/decorators/src/controller.ts) and reuses that instance for all incoming connections, both HTTP and WebSocket. Constructor-injected services (such as database repositories or event emitters) are initialized at this moment and shared across all requests. This approach ensures that stateful services maintain consistency when pushing real-time updates triggered by HTTP operations in the same controller instance.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →