Skip to content

Components & Services

1. SignalComponent

Mã nguồn: src/components/signal.component.tsKế thừa: BaseComponent

Component cấp cao nhất được Application đăng ký. Đóng vai trò điều phối, đăng ký service, controller, và component WebSocket lồng nhau.

Giai đoạn binding (binding()):

BướcHành độngLoại
1Đăng ký SignalEventServiceService
2Đăng ký WebSocketClientControllerController
3Đăng ký NxWebSocketComponentComponent
typescript
// 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

Mã nguồn: src/components/websocket.component.tsKế thừa: BaseComponent

Component cơ sở hạ tầng chính. Khởi tạo Redis, tạo ECDH instance, bind 6 WebSocket handler, tạo WebSocketEmitter, và đăng ký IGNIS WebSocketComponent.

2.1. Thuộc tính Instance

typescript
// 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>();
Thuộc tínhKiểuMục đích
ecdhECDHIGNIS ECDH helper instance (thuật toán P-256, HKDF info từ env)
_clientAesKeysMap<string, CryptoKey>Khóa AES-256-GCM theo từng client, khóa bằng client ID

2.2. Giai đoạn Binding

typescript
override async binding(): Promise<void> {
  const redis = this.initializeRedis();                           // 1. Kết nối Redis
  this.application.bind({ key: WebSocketBindingKeys.REDIS_CONNECTION }).toValue(redis);
  this.application.bind({ key: WebSocketBindingKeys.SERVER_OPTIONS }).toValue({
    path: '/stream',
    requireEncryption: true,
  });

  this.bindHandlers();                                            // 2. Tất cả 6 handlers
  await this.bindEmitter({ redis });                              // 3. WebSocketEmitter
  this.application.component(WebSocketComponent);                 // 4. IGNIS WebSocketComponent
}
BướcHành độngBinding Key
1Khởi tạo Redis (single hoặc cluster)WebSocketBindingKeys.REDIS_CONNECTION
1bĐặt server optionsWebSocketBindingKeys.SERVER_OPTIONS
2Bind 6 handler functionsXem Handler Bindings
3Tạo và cấu hình WebSocketEmitter@nx/signal/websocket-emitter
4Đăng ký IGNIS WebSocketComponent(internal IGNIS bindings)

2.3. Handler Bindings (6 Handlers)

#HandlerBinding KeyKiểuMục đích
1authenticateWebSocketBindingKeys.AUTHENTICATE_HANDLERTWebSocketAuthenticateFn<ISignalAuthPayload>Xác thực JWT Bearer token qua JWTTokenService, trả về { userId }
2handshakeWebSocketBindingKeys.HANDSHAKE_HANDLERTWebSocketHandshakeFn<ISignalAuthPayload>Trao đổi khóa ECDH: tạo cặp khóa, dẫn xuất khóa AES, lưu vào _clientAesKeys, trả về { serverPublicKey, salt }
3outboundTransformerWebSocketBindingKeys.OUTBOUND_TRANSFORMERTWebSocketOutboundTransformerMã hóa tin nhắn gửi đi bằng khóa AES của client. Bỏ qua sự kiện connectederror (trả về null).
4messageWebSocketBindingKeys.MESSAGE_HANDLERTWebSocketMessageHandlerGiải mã payload { iv, ct } đến bằng khóa AES của client. Xác thực cấu trúc payload trước khi giải mã.
5validateRoomWebSocketBindingKeys.VALIDATE_ROOM_HANDLERTWebSocketValidateRoomFnCho phép tất cả — chấp nhận mọi phòng được yêu cầu (({ rooms }) => rooms)
6clientDisconnectedWebSocketBindingKeys.CLIENT_DISCONNECTED_HANDLERTWebSocketClientDisconnectedFnDọn dẹp khóa AES khỏi _clientAesKeys map

2.4. Authentication Payload

typescript
// packages/signal/src/components/websocket.component.ts
interface ISignalAuthPayload extends Record<string, unknown> {
  type: string;            // Loại xác thực (chỉ hỗ trợ "Bearer")
  token: string;           // JWT token từ @nx/identity
  clientPublicKey?: string; // ECDH P-256 raw public key mã hóa base64
}

Luồng authenticate handler:

  1. Kiểm tra typetoken có mặt → trả về false nếu thiếu
  2. Switch theo type:
    • Authentication.TYPE_BEARER: Resolve JWTTokenService từ DI, gọi verify({ type, token }), trả về { userId: rs.userId.toString() } nếu thành công
    • Mặc định: Ghi log cảnh báo, trả về false
  3. Khi có lỗi: Ghi log lỗi, trả về false

2.5. Handshake Handler

Yêu cầu: data.clientPublicKey phải có mặt (từ chối nếu thiếu).

Luồng xử lý:

  1. this.ecdh.generateKeyPair(){ keyPair, publicKeyB64: serverPublicKey }
  2. this.ecdh.importPublicKey({ rawKeyB64: clientPubB64 })clientPub
  3. this.ecdh.deriveAESKey({ privateKey: keyPair.privateKey, peerPublicKey: clientPub }){ key: aesKey, salt }
  4. this._clientAesKeys.set(clientId, aesKey) — lưu trữ cho mã hóa/giải mã sau này
  5. Trả về { serverPublicKey, salt } — gửi cho client trong sự kiện connected

2.6. Khởi tạo Redis

Hỗ trợ hai chế độ dựa trên APP_ENV_WEBSOCKET_REDIS_MODE:

Chế độ single (mặc định):

Tham sốNguồnMặc định
nameHardcodedwebsocket-redis
hostAPP_ENV_WEBSOCKET_REDIS_HOSTlocalhost
portAPP_ENV_WEBSOCKET_REDIS_PORT6379
passwordAPP_ENV_WEBSOCKET_REDIS_PASSWORD
databaseAPP_ENV_WEBSOCKET_REDIS_DB0
maxRetryAPP_ENV_WEBSOCKET_REDIS_MAX_RETRY5
autoConnectHardcodedfalse

Chế độ cluster:

Tham sốNguồnMặc định
nameHardcodedwebsocket-redis-cluster
nodesAPP_ENV_WEBSOCKET_REDIS_CLUSTER_NODES (các cặp host:port phân cách bằng dấu phẩy)Bắt buộc
passwordAPP_ENV_WEBSOCKET_REDIS_PASSWORD
enableOfflineQueueHardcodedtrue

Chế độ không hợp lệ sẽ ném lỗi với thông báo: [NxWebSocketComponent] Invalid Redis Mode: ${mode} | Valid: [single, cluster]

2.7. WebSocketEmitter

Một instance WebSocketEmitter được tạo và bind vào @nx/signal/websocket-emitter. Điều này cho phép giao tiếp đa instance và liên dịch vụ:

typescript
// 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

Mã nguồn: src/services/signal-event.service.tsKế thừa: BaseServiceDI Dependencies: BaseApplication (cho lazy resolution của WebSocket helpers)

Service chính cho giao tiếp WebSocket. Sử dụng lazy gettersWebSocketServerHelper được bind qua post-start hook (không khả dụng trong quá trình khởi tạo DI).

3.1. Mô hình Lazy Getter

typescript
// 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. Phương thức truy vấn

Phương thứcTham sốTrả vềMô tả
isReady()booleanKiểm tra WebSocket server đã khởi tạo chưa (resolve WebSocketBindingKeys.WEBSOCKET_INSTANCE với isOptional: true)
getClientCount()numberĐếm client đang kết nối qua this.ws.getClients() dưới dạng Map<string, IWebSocketClient>
getClient(opts){ clientId: string }IWebSocketClient | undefinedLấy đối tượng WebSocket client thô theo ID
getClientInfo(opts){ clientId: string }TWebSocketClientInfo | undefinedLấy thông tin client đã serialize (an toàn cho API response)
getConnectedClientsInfo()TWebSocketClientInfo[]Liệt kê tất cả client đang kết nối (đã serialize)

3.3. Phương thức gửi tin nhắn

Phương thứcTham sốTrả vềMô tả
broadcast(opts){ topic: string, data: T }Promise<void>Gửi đến tất cả client (đa instance qua Redis pub/sub). Ủy quyền cho this.ws.send({ payload }).
sendToRoom(opts){ room: string, topic: string, data: T }Promise<void>Gửi đến tất cả client trong một phòng (đa instance). Ủy quyền cho this.ws.send({ destination: room, payload }).
sendToClient(opts){ clientId: string, topic: string, data: T }Promise<void>Gửi đến một client cụ thể. Gửi cục bộ nếu client ở instance này, ngược lại qua WebSocketEmitter.
disconnectClient(opts){ clientId: string }booleanBuộc đóng kết nối client qua client.socket.close(). Trả về false nếu không tìm thấy client.

Định tuyến đa instance trong sendToClient:

typescript
async sendToClient<T>(opts: { clientId: string; topic: string; data: T }) {
  const client = this.getClient({ clientId: opts.clientId });
  if (client) {
    // Client ở instance này — gửi cục bộ
    this.ws.sendToClient({ clientId: opts.clientId, event: opts.topic, data: opts.data });
  } else {
    // Client ở instance khác — publish qua Redis
    await this.emitter.toClient({ clientId: opts.clientId, event: opts.topic, data: opts.data });
  }
}

3.4. Serialize Client

typescript
// 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

typescript
// 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

Trường metadata luôn được đặt là {} trong serializeClient(). Nó tồn tại trong schema cho khả năng mở rộng trong tương lai.

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.