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ước | Hành động | Loại |
|---|---|---|
| 1 | Đăng ký SignalEventService | Service |
| 2 | Đăng ký WebSocketClientController | Controller |
| 3 | Đăng ký 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
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
// 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ính | Kiểu | Mục đích |
|---|---|---|
ecdh | ECDH | IGNIS ECDH helper instance (thuật toán P-256, HKDF info từ env) |
_clientAesKeys | Map<string, CryptoKey> | Khóa AES-256-GCM theo từng client, khóa bằng client ID |
2.2. Giai đoạn Binding
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ước | Hành động | Binding Key |
|---|---|---|
| 1 | Khởi tạo Redis (single hoặc cluster) | WebSocketBindingKeys.REDIS_CONNECTION |
| 1b | Đặt server options | WebSocketBindingKeys.SERVER_OPTIONS |
| 2 | Bind 6 handler functions | Xem Handler Bindings |
| 3 | Tạ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)
| # | Handler | Binding Key | Kiểu | Mục đích |
|---|---|---|---|---|
| 1 | authenticate | WebSocketBindingKeys.AUTHENTICATE_HANDLER | TWebSocketAuthenticateFn<ISignalAuthPayload> | Xác thực JWT Bearer token qua JWTTokenService, trả về { userId } |
| 2 | handshake | WebSocketBindingKeys.HANDSHAKE_HANDLER | TWebSocketHandshakeFn<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 } |
| 3 | outboundTransformer | WebSocketBindingKeys.OUTBOUND_TRANSFORMER | TWebSocketOutboundTransformer | Mã hóa tin nhắn gửi đi bằng khóa AES của client. Bỏ qua sự kiện connected và error (trả về null). |
| 4 | message | WebSocketBindingKeys.MESSAGE_HANDLER | TWebSocketMessageHandler | Giả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ã. |
| 5 | validateRoom | WebSocketBindingKeys.VALIDATE_ROOM_HANDLER | TWebSocketValidateRoomFn | Cho phép tất cả — chấp nhận mọi phòng được yêu cầu (({ rooms }) => rooms) |
| 6 | clientDisconnected | WebSocketBindingKeys.CLIENT_DISCONNECTED_HANDLER | TWebSocketClientDisconnectedFn | Dọn dẹp khóa AES khỏi _clientAesKeys map |
2.4. Authentication Payload
// 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:
- Kiểm tra
typevàtokencó mặt → trả vềfalsenếu thiếu - Switch theo
type:Authentication.TYPE_BEARER: ResolveJWTTokenServicetừ DI, gọiverify({ 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
- 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ý:
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)— lưu trữ cho mã hóa/giải mã sau này- Trả về
{ serverPublicKey, salt }— gửi cho client trong sự kiệnconnected
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ồn | Mặc định |
|---|---|---|
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 |
Chế độ cluster:
| Tham số | Nguồn | Mặc định |
|---|---|---|
name | Hardcoded | websocket-redis-cluster |
nodes | APP_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 |
password | APP_ENV_WEBSOCKET_REDIS_PASSWORD | — |
enableOfflineQueue | Hardcoded | true |
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ụ:
// 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 getters vì WebSocketServerHelper đượ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
// 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ức | Tham số | Trả về | Mô tả |
|---|---|---|---|
isReady() | — | boolean | Kiể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 | undefined | Lấy đối tượng WebSocket client thô theo ID |
getClientInfo(opts) | { clientId: string } | TWebSocketClientInfo | undefined | Lấ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ức | Tham 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 } | boolean | Buộ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:
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
// 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
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.