Components & Services
1. SignalComponent
Source: src/components/signal.component.tsExtends: BaseComponent
Top-level component registered by the Application. Acts as an orchestrator that registers the service, controller, and nested WebSocket component.
Binding phase (binding()):
| Step | Action | Type |
|---|---|---|
| 1 | Register SignalEventService | Service |
| 2 | Register WebSocketClientController | Controller |
| 3 | Register NxWebSocketComponent | Component |
// packages/signal/src/components/signal.component.ts
export class SignalComponent extends BaseComponent {
constructor(
@inject({ key: CoreBindings.APPLICATION_INSTANCE })
protected application: BaseApplication,
) {
super({
scope: SignalComponent.name,
initDefault: { enable: true, container: application },
bindings: {},
});
}
override async binding(): Promise<void> {
this.application.service(SignalEventService);
this.application.controller(WebSocketClientController);
this.application.component(NxWebSocketComponent);
}
}2. NxWebSocketComponent
Source: src/components/websocket.component.tsExtends: BaseComponent
The core infrastructure component. Initializes Redis, creates ECDH instance, binds all 6 WebSocket handlers, creates WebSocketEmitter, and registers the IGNIS WebSocketComponent.
2.1. Instance Properties
// packages/signal/src/components/websocket.component.ts
private readonly ecdh = new ECDH({
algorithm: 'ecdh-p256',
hkdfInfo: applicationEnvironment.get<string>(EnvironmentKeys.APP_ENV_WEBSOCKET_ECDH_INFO),
});
private readonly _clientAesKeys = new Map<string, CryptoKey>();| Property | Type | Purpose |
|---|---|---|
ecdh | ECDH | IGNIS ECDH helper instance (P-256 algorithm, HKDF info from env) |
_clientAesKeys | Map<string, CryptoKey> | Per-client AES-256-GCM keys, keyed by client ID |
2.2. Binding Phase
override async binding(): Promise<void> {
const redis = this.initializeRedis(); // 1. Redis connection
this.application.bind({ key: WebSocketBindingKeys.REDIS_CONNECTION }).toValue(redis);
this.application.bind({ key: WebSocketBindingKeys.SERVER_OPTIONS }).toValue({
path: '/stream',
requireEncryption: true,
});
this.bindHandlers(); // 2. All 6 handlers
await this.bindEmitter({ redis }); // 3. WebSocketEmitter
this.application.component(WebSocketComponent); // 4. IGNIS WebSocketComponent
}| Step | Action | Binding Key |
|---|---|---|
| 1 | Initialize Redis (single or cluster) | WebSocketBindingKeys.REDIS_CONNECTION |
| 1b | Set server options | WebSocketBindingKeys.SERVER_OPTIONS |
| 2 | Bind 6 handler functions | See Handler Bindings |
| 3 | Create and configure WebSocketEmitter | @nx/signal/websocket-emitter |
| 4 | Register IGNIS WebSocketComponent | (internal IGNIS bindings) |
2.3. Handler Bindings (6 Handlers)
| # | Handler | Binding Key | Type | Purpose |
|---|---|---|---|---|
| 1 | authenticate | WebSocketBindingKeys.AUTHENTICATE_HANDLER | TWebSocketAuthenticateFn<ISignalAuthPayload> | Verify JWT Bearer token via JWTTokenService, return { userId } |
| 2 | handshake | WebSocketBindingKeys.HANDSHAKE_HANDLER | TWebSocketHandshakeFn<ISignalAuthPayload> | ECDH key exchange: generate key pair, derive AES key, store in _clientAesKeys, return { serverPublicKey, salt } |
| 3 | outboundTransformer | WebSocketBindingKeys.OUTBOUND_TRANSFORMER | TWebSocketOutboundTransformer | Encrypt outgoing messages with client's AES key. Skip connected and error events (return null). |
| 4 | message | WebSocketBindingKeys.MESSAGE_HANDLER | TWebSocketMessageHandler | Decrypt incoming { iv, ct } payloads with client's AES key. Validates payload structure before decryption. |
| 5 | validateRoom | WebSocketBindingKeys.VALIDATE_ROOM_HANDLER | TWebSocketValidateRoomFn | Passthrough — accepts all requested rooms (({ rooms }) => rooms) |
| 6 | clientDisconnected | WebSocketBindingKeys.CLIENT_DISCONNECTED_HANDLER | TWebSocketClientDisconnectedFn | Clean up AES key from _clientAesKeys map |
2.4. Authentication Payload
// packages/signal/src/components/websocket.component.ts
interface ISignalAuthPayload extends Record<string, unknown> {
type: string; // Authentication type (only "Bearer" supported)
token: string; // JWT token from @nx/identity
clientPublicKey?: string; // Base64-encoded ECDH P-256 raw public key
}Authenticate handler flow:
- Check
typeandtokenare present → returnfalseif missing - Switch on
type:Authentication.TYPE_BEARER: ResolveJWTTokenServicefrom DI, callverify({ type, token }), return{ userId: rs.userId.toString() }on success- Default: Log warning, return
false
- On any error: Log error, return
false
2.5. Handshake Handler
Requirements: data.clientPublicKey must be present (rejects if missing).
Flow:
this.ecdh.generateKeyPair()→{ keyPair, publicKeyB64: serverPublicKey }this.ecdh.importPublicKey({ rawKeyB64: clientPubB64 })→clientPubthis.ecdh.deriveAESKey({ privateKey: keyPair.privateKey, peerPublicKey: clientPub })→{ key: aesKey, salt }this._clientAesKeys.set(clientId, aesKey)— store for future encrypt/decrypt- Return
{ serverPublicKey, salt }— sent to client inconnectedevent
2.6. Redis Initialization
Supports two modes based on APP_ENV_WEBSOCKET_REDIS_MODE:
Single mode (default):
| Parameter | Source | Default |
|---|---|---|
name | Hardcoded | websocket-redis |
host | APP_ENV_WEBSOCKET_REDIS_HOST | localhost |
port | APP_ENV_WEBSOCKET_REDIS_PORT | 6379 |
password | APP_ENV_WEBSOCKET_REDIS_PASSWORD | — |
database | APP_ENV_WEBSOCKET_REDIS_DB | 0 |
maxRetry | APP_ENV_WEBSOCKET_REDIS_MAX_RETRY | 5 |
autoConnect | Hardcoded | false |
Cluster mode:
| Parameter | Source | Default |
|---|---|---|
name | Hardcoded | websocket-redis-cluster |
nodes | APP_ENV_WEBSOCKET_REDIS_CLUSTER_NODES (comma-separated host:port) | Required |
password | APP_ENV_WEBSOCKET_REDIS_PASSWORD | — |
enableOfflineQueue | Hardcoded | true |
Invalid mode throws an error with message: [NxWebSocketComponent] Invalid Redis Mode: ${mode} | Valid: [single, cluster]
2.7. WebSocketEmitter
A WebSocketEmitter instance is created and bound to @nx/signal/websocket-emitter. This enables cross-instance and cross-service messaging:
// packages/signal/src/components/websocket.component.ts — bindEmitter()
private async bindEmitter(opts: { redis: DefaultRedisHelper }) {
const emitter = new WebSocketEmitter({
identifier: 'signal-ws-emitter',
redisConnection: opts.redis,
});
await emitter.configure();
this.application.bind({ key: NxBindingKeys.WEBSOCKET_EMITTER }).toValue(emitter);
}3. SignalEventService
Source: src/services/signal-event.service.tsExtends: BaseServiceDI Dependencies: BaseApplication (for lazy resolution of WebSocket helpers)
Core service for WebSocket messaging. Uses lazy getters because WebSocketServerHelper is bound via a post-start hook (not available during DI construction).
3.1. Lazy Getter Pattern
// packages/signal/src/services/signal-event.service.ts
private _ws: WebSocketServerHelper | null = null;
private _emitter: WebSocketEmitter | null = null;
private get ws(): WebSocketServerHelper {
if (!this._ws) {
this._ws = this._application.get<WebSocketServerHelper>({
key: WebSocketBindingKeys.WEBSOCKET_INSTANCE,
isOptional: true,
}) ?? null;
}
if (!this._ws) {
throw getError({ message: '[SignalEventService] WebSocket not initialized' });
}
return this._ws;
}
private get emitter(): WebSocketEmitter {
if (!this._emitter) {
this._emitter = this._application.get<WebSocketEmitter>({
key: NxBindingKeys.WEBSOCKET_EMITTER,
isOptional: true,
}) ?? null;
}
if (!this._emitter) {
throw getError({ message: '[SignalEventService] WebSocketEmitter not initialized' });
}
return this._emitter;
}3.2. Query Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
isReady() | — | boolean | Check if WebSocket server is initialized (resolves WebSocketBindingKeys.WEBSOCKET_INSTANCE with isOptional: true) |
getClientCount() | — | number | Get count of connected clients via this.ws.getClients() as Map<string, IWebSocketClient> |
getClient(opts) | { clientId: string } | IWebSocketClient | undefined | Get raw WebSocket client object by ID |
getClientInfo(opts) | { clientId: string } | TWebSocketClientInfo | undefined | Get serialized client info (safe for API responses) |
getConnectedClientsInfo() | — | TWebSocketClientInfo[] | List all connected clients (serialized) |
3.3. Messaging Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
broadcast(opts) | { topic: string, data: T } | Promise<void> | Send to all clients (cross-instance via Redis pub/sub). Delegates to this.ws.send({ payload }). |
sendToRoom(opts) | { room: string, topic: string, data: T } | Promise<void> | Send to all clients in a room (cross-instance). Delegates to this.ws.send({ destination: room, payload }). |
sendToClient(opts) | { clientId: string, topic: string, data: T } | Promise<void> | Send to a specific client. Local delivery if on this instance, otherwise via WebSocketEmitter. |
disconnectClient(opts) | { clientId: string } | boolean | Force-close client connection via client.socket.close(). Returns false if client not found. |
Cross-instance routing in sendToClient:
async sendToClient<T>(opts: { clientId: string; topic: string; data: T }) {
const client = this.getClient({ clientId: opts.clientId });
if (client) {
// Client is on this instance — deliver locally
this.ws.sendToClient({ clientId: opts.clientId, event: opts.topic, data: opts.data });
} else {
// Client is on another instance — publish via Redis
await this.emitter.toClient({ clientId: opts.clientId, event: opts.topic, data: opts.data });
}
}3.4. Client Serialization
// packages/signal/src/services/signal-event.service.ts
private serializeClient(client: IWebSocketClient): TWebSocketClientInfo {
return {
id: client.id,
state: client.state,
encrypted: client.encrypted,
rooms: Array.from(client.rooms),
connectedAt: client.connectedAt,
lastActivity: client.lastActivity,
metadata: {},
};
}3.5. TWebSocketClientInfo Schema
// packages/signal/src/common/types.ts
const WebSocketClientInfoSchema = z.object({
id: z.string(),
state: z.string(),
encrypted: z.boolean(),
rooms: z.array(z.string()),
connectedAt: z.number(),
lastActivity: z.number(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
type TWebSocketClientInfo = z.infer<typeof WebSocketClientInfoSchema>;NOTE
The metadata field is always set to {} in serializeClient(). It exists in the schema for future extensibility.