@tamng.npm/websocket 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +647 -0
- package/dist/client/browser.client.d.ts +12 -0
- package/dist/client/browser.client.js +42 -0
- package/dist/client/index.d.ts +3 -0
- package/dist/client/index.js +2 -0
- package/dist/client/node.client.d.ts +19 -0
- package/dist/client/node.client.js +70 -0
- package/dist/core/base-client.d.ts +47 -0
- package/dist/core/base-client.js +139 -0
- package/dist/core/event-emitter.d.ts +11 -0
- package/dist/core/event-emitter.js +26 -0
- package/dist/core/protocol.d.ts +11 -0
- package/dist/core/protocol.js +12 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/server/adapters/adapter.d.ts +40 -0
- package/dist/server/adapters/adapter.js +1 -0
- package/dist/server/adapters/memory.adapter.d.ts +20 -0
- package/dist/server/adapters/memory.adapter.js +78 -0
- package/dist/server/adapters/nats.adapter.d.ts +60 -0
- package/dist/server/adapters/nats.adapter.js +157 -0
- package/dist/server/index.d.ts +11 -0
- package/dist/server/index.js +6 -0
- package/dist/server/socket-connection.d.ts +45 -0
- package/dist/server/socket-connection.js +118 -0
- package/dist/server/socket-link.d.ts +8 -0
- package/dist/server/socket-link.js +14 -0
- package/dist/server/websocket-server.d.ts +58 -0
- package/dist/server/websocket-server.js +153 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 maxsida <maxsida.dev@gmail.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
# @vutotoite/websocket
|
|
2
|
+
|
|
3
|
+
Thư viện WebSocket viết bằng TypeScript, hỗ trợ Server và Client (Node.js + trình duyệt), **scale theo chiều ngang qua NATS**.
|
|
4
|
+
|
|
5
|
+
## 📦 Cài đặt
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @vutotoite/websocket
|
|
9
|
+
|
|
10
|
+
# Tùy chọn: chỉ cần khi muốn scale ngang qua NATS
|
|
11
|
+
npm install @nats-io/transport-node
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
> `nats` là **optional peer dependency**. Nếu chỉ chạy 1 instance (MemoryAdapter), bạn không cần cài.
|
|
15
|
+
|
|
16
|
+
## ✨ Tính năng
|
|
17
|
+
|
|
18
|
+
### Server
|
|
19
|
+
- ✅ Singleton pattern, tích hợp HTTP server
|
|
20
|
+
- ✅ Xác thực (Authentication) tùy chỉnh
|
|
21
|
+
- ✅ Quản lý phòng (Room) và client
|
|
22
|
+
- ✅ Middleware chain với throw error (dừng ngay khi lỗi)
|
|
23
|
+
- ✅ Ping/Pong tự động
|
|
24
|
+
- ✅ **Adapter pattern**: `MemoryAdapter` (1 node) hoặc `NatsAdapter` (nhiều node)
|
|
25
|
+
- ✅ **Scale ngang qua NATS** — broadcast/room message hoạt động xuyên node
|
|
26
|
+
- ✅ Codec tùy chỉnh (mặc định JSON)
|
|
27
|
+
|
|
28
|
+
### Client
|
|
29
|
+
- ✅ Hỗ trợ Node.js và Browser, dùng chung logic qua `BaseClient`
|
|
30
|
+
- ✅ API `on`/`emit` đơn giản
|
|
31
|
+
- ✅ Tự động kết nối lại, queue tin nhắn khi offline
|
|
32
|
+
- ✅ Heartbeat tự động (Node)
|
|
33
|
+
|
|
34
|
+
## 🏗️ Kiến trúc & Scale ngang
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
┌─────────── NATS ───────────┐
|
|
38
|
+
│ (message bus chung) │
|
|
39
|
+
└──────┬───────────────┬───────┘
|
|
40
|
+
│ │
|
|
41
|
+
┌────────▼──────┐ ┌──────▼────────┐
|
|
42
|
+
│ Node A │ │ Node B │
|
|
43
|
+
│ WsServer │ │ WsServer │
|
|
44
|
+
│ +NatsAdapter │ │ +NatsAdapter │
|
|
45
|
+
└────┬─────┬────┘ └────┬─────┬────┘
|
|
46
|
+
client client client client
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Mỗi node giữ kết nối WebSocket của riêng nó. Khi `toRooms`/`toAll`/`toClients` phát message, `NatsAdapter` publish lên subject NATS phù hợp; node nào quan tâm sẽ nhận và deliver tới client local của mình. Nhờ đó broadcast hoạt động xuyên nhiều instance.
|
|
50
|
+
|
|
51
|
+
- **`MemoryAdapter`** (mặc định): state trong RAM, dùng cho 1 instance.
|
|
52
|
+
- **`NatsAdapter`**: nhận `NatsConnection` **truyền từ ngoài vào** (dependency injection), định tuyến qua core NATS pub/sub.
|
|
53
|
+
|
|
54
|
+
### Định tuyến theo subject
|
|
55
|
+
|
|
56
|
+
`NatsAdapter` định tuyến theo loại target để giảm traffic xuyên node (subject mặc định prefix `ws`):
|
|
57
|
+
|
|
58
|
+
| Target | Subject publish | Subscription của node |
|
|
59
|
+
|--------|----------------|----------------------|
|
|
60
|
+
| `toAll()` | `ws.broadcast` | luôn subscribe `ws.broadcast` |
|
|
61
|
+
| `toClients(id)` | `ws.client.<id>` | wildcard `ws.client.*`, lọc client local |
|
|
62
|
+
| `toRooms(id)` | `ws.room.<id>` | **chỉ subscribe room khi có client local**; tự unsubscribe khi client cuối rời |
|
|
63
|
+
|
|
64
|
+
Điểm quan trọng: node **không** có client trong một room sẽ **không** nhận traffic của room đó. Subscription room được tạo/hủy tự động theo `join`/`leave`.
|
|
65
|
+
|
|
66
|
+
> **Về `toClients` xuyên node:** không có presence registry (`clientId → nodeId`), nên message client được publish và mọi node nhận qua wildcard rồi lọc local. Đây là tradeoff của thiết kế core NATS thuần. Nếu cần định tuyến client chính xác (chỉ node chứa client nhận), có thể bổ sung presence registry sau.
|
|
67
|
+
|
|
68
|
+
## 🚀 Sử dụng
|
|
69
|
+
|
|
70
|
+
### 1. Server cơ bản (1 instance — MemoryAdapter)
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
import { Server } from '@vutotoite/websocket';
|
|
74
|
+
import http from 'http';
|
|
75
|
+
import express from 'express';
|
|
76
|
+
|
|
77
|
+
const app = express();
|
|
78
|
+
const httpServer = http.createServer(app);
|
|
79
|
+
|
|
80
|
+
// init() là singleton: lần gọi đầu tạo instance, các lần sau trả lại đúng instance đó.
|
|
81
|
+
// noServer: true để tự kiểm soát quá trình HTTP upgrade qua attachServer().
|
|
82
|
+
const wsServer = Server.WebsocketServer.init({
|
|
83
|
+
ws: { noServer: true }
|
|
84
|
+
// adapter mặc định = MemoryAdapter (state trong RAM, 1 instance)
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Gắn vào HTTP server: lib lắng nghe sự kiện 'upgrade' để bắt tay WebSocket.
|
|
88
|
+
wsServer.attachServer(httpServer);
|
|
89
|
+
|
|
90
|
+
wsServer.connected({
|
|
91
|
+
// connectionHandler chạy MỖI khi có client mới kết nối thành công.
|
|
92
|
+
connectionHandler: (ws, wss) => {
|
|
93
|
+
console.log('Client đã kết nối');
|
|
94
|
+
|
|
95
|
+
// onS đăng ký listener cho event 'message' gửi từ client ({ event, data }).
|
|
96
|
+
ws.onS('message', (data) => {
|
|
97
|
+
console.log('Nhận:', data);
|
|
98
|
+
// emitS gửi event về CHÍNH client này.
|
|
99
|
+
ws.emitS('response', { status: 'ok' });
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
// errorHandler nhận mọi lỗi: parse message hỏng, hoặc throw trong middleware chain.
|
|
103
|
+
errorHandler: (error, ws) => {
|
|
104
|
+
ws.emitS('error', { message: error.message });
|
|
105
|
+
},
|
|
106
|
+
// closeHandler chạy khi client ngắt kết nối (đã được dọn khỏi room/adapter).
|
|
107
|
+
closeHandler: (ws) => {
|
|
108
|
+
console.log('Client ngắt kết nối');
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Lưu ý: vòng đời http.Server do BẠN quản lý — lib không tự listen/close nó.
|
|
113
|
+
httpServer.listen(8080, () => console.log('Server chạy trên cổng 8080'));
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### 2. Scale ngang với NATS
|
|
117
|
+
|
|
118
|
+
Connection NATS được **tạo bên ngoài và truyền vào** `NatsAdapter`:
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
import { Server } from '@vutotoite/websocket';
|
|
122
|
+
import { connect } from '@nats-io/transport-node'; // hoặc 'nats'
|
|
123
|
+
import http from 'http';
|
|
124
|
+
|
|
125
|
+
// Bạn TỰ tạo kết nối NATS và quản lý vòng đời của nó (lib không tự connect).
|
|
126
|
+
const nats = await connect({ servers: 'nats://localhost:4222' });
|
|
127
|
+
|
|
128
|
+
const wsServer = Server.WebsocketServer.init({
|
|
129
|
+
ws: { noServer: true },
|
|
130
|
+
// Inject connection vào NatsAdapter -> mọi broadcast định tuyến qua NATS.
|
|
131
|
+
adapter: new Server.NatsAdapter(nats)
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
wsServer.attachServer(http.createServer().listen(8080));
|
|
135
|
+
|
|
136
|
+
wsServer.connected({
|
|
137
|
+
connectionHandler: (ws, wss) => {
|
|
138
|
+
ws.onS('send_message', (data: { room_id: string; message: string }) => {
|
|
139
|
+
// wss.toRooms(...).emitS(...): NatsAdapter publish lên subject ws.room.<id>.
|
|
140
|
+
// Mọi node CÓ client trong room đó sẽ nhận và deliver tới client local.
|
|
141
|
+
// -> client ở MỌI node (kể cả node khác) đều nhận được new_message.
|
|
142
|
+
wss.toRooms(data.room_id).emitS('new_message', {
|
|
143
|
+
from: ws.getId(),
|
|
144
|
+
message: data.message
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
},
|
|
148
|
+
errorHandler: (error, ws) => ws.emitS('error', { message: error.message })
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Chạy nhiều instance (mỗi instance 1 cổng / 1 container) cùng trỏ về NATS → tự động đồng bộ.
|
|
153
|
+
> **Lưu ý vận hành:** dùng load balancer với **sticky session** (hoặc bất kỳ LB nào, vì state được đồng bộ qua NATS) để client giữ kết nối ổn định. `NatsAdapter` hiện dùng core pub/sub (không lưu trữ message), phù hợp real-time.
|
|
154
|
+
|
|
155
|
+
#### Graceful shutdown
|
|
156
|
+
|
|
157
|
+
`close()` đóng có trật tự: ngừng nhận kết nối mới, đóng client local với close code `1001` ("going away"), dọn subscription NATS của node (không ảnh hưởng node khác), rồi chờ ws server đóng hẳn. **Không** đóng `http.Server` của bạn — vòng đời đó do bạn quản lý.
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
// Tự gọi close() khi nhận SIGTERM/SIGINT (K8s, docker stop)
|
|
161
|
+
wsServer.enableGracefulShutdown(); // mặc định ['SIGTERM', 'SIGINT']
|
|
162
|
+
|
|
163
|
+
// Hoặc tự quản lý
|
|
164
|
+
process.on('SIGTERM', async () => {
|
|
165
|
+
await wsServer.close();
|
|
166
|
+
httpServer.close(); // đóng http server của bạn nếu cần
|
|
167
|
+
process.exit(0);
|
|
168
|
+
});
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
### 3. Tổ chức trong NestJS
|
|
173
|
+
|
|
174
|
+
Mẫu khuyến nghị: bọc thư viện trong một **module** + **service**. Connection NATS được inject qua provider; server WebSocket được khởi tạo trong `onModuleInit` và gắn vào HTTP server của Nest; handler kết nối đăng ký trong một gateway service.
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
// nats.provider.ts
|
|
178
|
+
// Provider tạo & quản lý vòng đời kết nối NATS (lib KHÔNG tự connect).
|
|
179
|
+
import { connect, NatsConnection } from 'nats';
|
|
180
|
+
import { Provider } from '@nestjs/common';
|
|
181
|
+
|
|
182
|
+
export const NATS_CONNECTION = 'NATS_CONNECTION';
|
|
183
|
+
|
|
184
|
+
export const NatsProvider: Provider = {
|
|
185
|
+
provide: NATS_CONNECTION,
|
|
186
|
+
// useFactory chạy 1 lần khi module khởi tạo -> trả về connection dùng chung.
|
|
187
|
+
useFactory: async (): Promise<NatsConnection> =>
|
|
188
|
+
connect({ servers: process.env.NATS_URL ?? 'nats://localhost:4222' }),
|
|
189
|
+
};
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
// websocket.service.ts
|
|
194
|
+
// Service bọc WebsocketServer: khởi tạo singleton, cấu hình auth + handler.
|
|
195
|
+
import { Inject, Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
|
196
|
+
import { HttpAdapterHost } from '@nestjs/core';
|
|
197
|
+
import { NatsConnection } from 'nats';
|
|
198
|
+
import { Server } from '@vutotoite/websocket';
|
|
199
|
+
import { NATS_CONNECTION } from './nats.provider';
|
|
200
|
+
|
|
201
|
+
@Injectable()
|
|
202
|
+
export class WebsocketService implements OnModuleInit, OnModuleDestroy {
|
|
203
|
+
private wsServer!: Server.WebsocketServer;
|
|
204
|
+
|
|
205
|
+
constructor(
|
|
206
|
+
@Inject(NATS_CONNECTION) private readonly nats: NatsConnection,
|
|
207
|
+
// HttpAdapterHost cho phép lấy http.Server thật mà Nest đang chạy.
|
|
208
|
+
private readonly httpAdapterHost: HttpAdapterHost,
|
|
209
|
+
) {}
|
|
210
|
+
|
|
211
|
+
onModuleInit() {
|
|
212
|
+
// init() là singleton: chỉ tạo instance ở lần gọi đầu tiên.
|
|
213
|
+
this.wsServer = Server.WebsocketServer.init({
|
|
214
|
+
ws: { noServer: true }, // tự kiểm soát HTTP upgrade
|
|
215
|
+
adapter: new Server.NatsAdapter(this.nats), // inject connection -> scale ngang
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Lấy http.Server thật của Nest và gắn vào -> lib bắt sự kiện 'upgrade'.
|
|
219
|
+
const httpServer = this.httpAdapterHost.httpAdapter.getHttpServer();
|
|
220
|
+
this.wsServer.attachServer(httpServer);
|
|
221
|
+
|
|
222
|
+
// Xác thực: throw -> server trả 401 và từ chối kết nối.
|
|
223
|
+
this.wsServer.setAuth(async (req) => {
|
|
224
|
+
const token = req.headers.authorization?.split(' ')[1];
|
|
225
|
+
if (!token) throw new Error('Unauthorized');
|
|
226
|
+
return await this.verifyToken(token); // -> ws.getAuthData()
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Tự gọi close() khi nhận SIGTERM/SIGINT (graceful shutdown trong K8s).
|
|
230
|
+
this.wsServer.enableGracefulShutdown();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Dọn dẹp khi module bị huỷ (vd hot-reload): đóng có trật tự + drain NATS.
|
|
234
|
+
async onModuleDestroy() {
|
|
235
|
+
await this.wsServer.close();
|
|
236
|
+
await this.nats.drain();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Cho phép service khác lấy server để emit (vd từ REST controller).
|
|
240
|
+
getServer(): Server.WebsocketServer {
|
|
241
|
+
return this.wsServer;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private async verifyToken(token: string): Promise<{ userId: string }> {
|
|
245
|
+
// ... xác thực token, trả về payload
|
|
246
|
+
return { userId: 'demo' };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
// chat.gateway.ts
|
|
253
|
+
// Đăng ký handler kết nối/sự kiện. Tách khỏi service hạ tầng cho gọn.
|
|
254
|
+
import { Injectable, OnModuleInit } from '@nestjs/common';
|
|
255
|
+
import { WebsocketService } from './websocket.service';
|
|
256
|
+
|
|
257
|
+
@Injectable()
|
|
258
|
+
export class ChatGateway implements OnModuleInit {
|
|
259
|
+
constructor(private readonly ws: WebsocketService) {}
|
|
260
|
+
|
|
261
|
+
onModuleInit() {
|
|
262
|
+
this.ws.getServer().connected({
|
|
263
|
+
connectionHandler: (socket, server) => {
|
|
264
|
+
// authData lấy từ setAuth ở trên; gán id + cho vào room riêng của user.
|
|
265
|
+
const auth = socket.getAuthData<{ userId: string }>();
|
|
266
|
+
socket.setId(auth.userId);
|
|
267
|
+
socket.join(auth.userId);
|
|
268
|
+
|
|
269
|
+
// Lắng nghe client gửi 'send_message' -> phát tới room (xuyên node qua NATS).
|
|
270
|
+
socket.onS('send_message', (data: { room_id: string; message: string }) => {
|
|
271
|
+
server.toRooms(data.room_id).emitS('new_message', {
|
|
272
|
+
from: socket.getId(),
|
|
273
|
+
message: data.message,
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
},
|
|
277
|
+
errorHandler: (error, socket) => socket.emitS('error', { message: error.message }),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
```typescript
|
|
284
|
+
// websocket.module.ts
|
|
285
|
+
import { Module } from '@nestjs/common';
|
|
286
|
+
import { NatsProvider } from './nats.provider';
|
|
287
|
+
import { WebsocketService } from './websocket.service';
|
|
288
|
+
import { ChatGateway } from './chat.gateway';
|
|
289
|
+
|
|
290
|
+
@Module({
|
|
291
|
+
providers: [NatsProvider, WebsocketService, ChatGateway],
|
|
292
|
+
exports: [WebsocketService], // export để controller/service khác emit được
|
|
293
|
+
})
|
|
294
|
+
export class WebsocketModule {}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
```typescript
|
|
298
|
+
// notification.controller.ts
|
|
299
|
+
// Emit từ REST API: tái dùng WebsocketService -> đẩy event tới client cụ thể.
|
|
300
|
+
import { Body, Controller, Post } from '@nestjs/common';
|
|
301
|
+
import { WebsocketService } from './websocket.service';
|
|
302
|
+
|
|
303
|
+
@Controller('notify')
|
|
304
|
+
export class NotificationController {
|
|
305
|
+
constructor(private readonly ws: WebsocketService) {}
|
|
306
|
+
|
|
307
|
+
@Post()
|
|
308
|
+
notify(@Body() body: { userId: string; message: string }) {
|
|
309
|
+
// toClients(userId): tới client ở bất kỳ node nào (định tuyến qua NATS).
|
|
310
|
+
this.ws.getServer().toClients(body.userId).emitS('notification', {
|
|
311
|
+
message: body.message,
|
|
312
|
+
});
|
|
313
|
+
return { success: true };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
> Thứ tự lifecycle: `NatsProvider.useFactory` (tạo connection) → `WebsocketService.onModuleInit` (init + attach + auth) → `ChatGateway.onModuleInit` (đăng ký handler). Vì `init()` là singleton, mọi service đều thao tác trên cùng một server instance.
|
|
319
|
+
|
|
320
|
+
### 4. Xác thực
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
wsServer.setAuth(async (req, query) => {
|
|
324
|
+
const token = req.headers.authorization?.split(' ')[1];
|
|
325
|
+
if (!token) throw new Error('Unauthorized');
|
|
326
|
+
|
|
327
|
+
const user = await verifyToken(token);
|
|
328
|
+
return user; // được truyền vào ws.getAuthData()
|
|
329
|
+
});
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Throw error trong `setAuth` → server trả `401` và từ chối kết nối.
|
|
333
|
+
|
|
334
|
+
### 5. onInstanceInit (NestJS / module isolation)
|
|
335
|
+
|
|
336
|
+
```typescript
|
|
337
|
+
// websocket-config.ts
|
|
338
|
+
import { Server } from '@vutotoite/websocket';
|
|
339
|
+
|
|
340
|
+
Server.WebsocketServer.onInstanceInit((wsServer) => {
|
|
341
|
+
wsServer.setAuth(async (req, query) => {
|
|
342
|
+
const token = req.headers.authorization?.split(' ')[1];
|
|
343
|
+
if (!token) throw new Error('Unauthorized');
|
|
344
|
+
return await verifyToken(token);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
wsServer.connected({
|
|
348
|
+
connectionHandler: (ws) => {
|
|
349
|
+
const auth = ws.getAuthData();
|
|
350
|
+
ws.setId(auth.userId);
|
|
351
|
+
ws.join(auth.userId);
|
|
352
|
+
},
|
|
353
|
+
errorHandler: (error, ws) => ws.emitS('error', { message: error.message })
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// server.ts
|
|
358
|
+
import './websocket-config';
|
|
359
|
+
import { Server } from '@vutotoite/websocket';
|
|
360
|
+
|
|
361
|
+
const wsServer = Server.WebsocketServer.init({ ws: { noServer: true } });
|
|
362
|
+
wsServer.attachServer(httpServer); // callback đã đăng ký chạy ngay khi init
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
- Server đã init → callback chạy **ngay lập tức**.
|
|
366
|
+
- Chưa init → callback chạy **sau khi** `init()` được gọi.
|
|
367
|
+
- Callback chỉ chạy **một lần**.
|
|
368
|
+
|
|
369
|
+
### 6. Query parameters
|
|
370
|
+
|
|
371
|
+
```typescript
|
|
372
|
+
wsServer.connected({
|
|
373
|
+
connectionHandler: (ws) => {
|
|
374
|
+
// ws://localhost:8080?user_id=123&room=general
|
|
375
|
+
const query = ws.getQuery<{ user_id: string; room: string }>();
|
|
376
|
+
ws.setId(query.user_id);
|
|
377
|
+
ws.join(query.room);
|
|
378
|
+
},
|
|
379
|
+
errorHandler: (error, ws) => ws.emitS('error', { message: error.message })
|
|
380
|
+
});
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### 7. Middleware chain với throw error
|
|
384
|
+
|
|
385
|
+
Middleware chạy **tuần tự trái → phải**. Khi một middleware throw, chain **dừng ngay** và `errorHandler` được gọi.
|
|
386
|
+
|
|
387
|
+
```typescript
|
|
388
|
+
class WsError extends Error {
|
|
389
|
+
constructor(message: string, public code = 400) {
|
|
390
|
+
super(message);
|
|
391
|
+
this.name = 'WsError';
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const validateRoom = (data: { room_id?: string }) => {
|
|
396
|
+
if (!data?.room_id) throw new WsError('Room ID bắt buộc', 400);
|
|
397
|
+
if (!wsServer.isExistRoom(data.room_id)) throw new WsError('Phòng không tồn tại', 404);
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const validateMessage = (data: { message?: string }) => {
|
|
401
|
+
if (!data?.message?.trim()) throw new WsError('Tin nhắn rỗng', 400);
|
|
402
|
+
if (data.message.length > 1000) throw new WsError('Tin nhắn quá dài', 400);
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
wsServer.connected({
|
|
406
|
+
connectionHandler: (ws, wss) => {
|
|
407
|
+
ws.onS(
|
|
408
|
+
'send_message',
|
|
409
|
+
validateRoom, // dừng chain nếu throw
|
|
410
|
+
validateMessage,
|
|
411
|
+
(data: { room_id: string; message: string }) => {
|
|
412
|
+
wss.toRooms(data.room_id).emitS('new_message', {
|
|
413
|
+
message: data.message,
|
|
414
|
+
from: ws.getId()
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
);
|
|
418
|
+
},
|
|
419
|
+
errorHandler: (error, ws) => {
|
|
420
|
+
if (error instanceof WsError) {
|
|
421
|
+
ws.emitS('validation_error', { message: error.message, code: error.code });
|
|
422
|
+
} else {
|
|
423
|
+
console.error('Unexpected:', error);
|
|
424
|
+
ws.emitS('server_error', { message: 'Lỗi không mong muốn' });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
> Mỗi middleware nhận `(data, prevResult)` — `prevResult` là giá trị trả về của middleware trước, cho phép truyền dữ liệu qua chain.
|
|
431
|
+
|
|
432
|
+
### 8. Quản lý phòng
|
|
433
|
+
|
|
434
|
+
```typescript
|
|
435
|
+
wsServer.connected({
|
|
436
|
+
connectionHandler: (ws, wss) => {
|
|
437
|
+
ws.onS('join_room', (data: { room_id: string }) => {
|
|
438
|
+
ws.join(data.room_id);
|
|
439
|
+
ws.emitS('joined', { room: data.room_id });
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
ws.onS('leave_room', (data: { room_id: string }) => {
|
|
443
|
+
ws.leave(data.room_id);
|
|
444
|
+
ws.emitS('left', { room: data.room_id });
|
|
445
|
+
});
|
|
446
|
+
},
|
|
447
|
+
errorHandler: (error, ws) => ws.emitS('error', { message: error.message })
|
|
448
|
+
});
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
### 9. Gửi message tới target
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
wsServer.toAll().emitS('broadcast', { message: 'Hello everyone!' });
|
|
455
|
+
wsServer.toClients('user_123', 'user_456').emitS('private', { message: 'Hi' });
|
|
456
|
+
wsServer.toRooms('room_1', 'room_2').emitS('announcement', { message: '...' });
|
|
457
|
+
|
|
458
|
+
// Lọc client LOCAL trên node hiện tại (không xuyên node)
|
|
459
|
+
wsServer.filter((c) => c.getVariable('premium') === true)
|
|
460
|
+
.emitS('premium_offer', { discount: 50 });
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
> `filter()` chỉ duyệt client kết nối tại node hiện tại. Trong môi trường nhiều node, dùng room hoặc client id để định tuyến chính xác xuyên node.
|
|
464
|
+
|
|
465
|
+
### 10. Biến tùy chỉnh
|
|
466
|
+
|
|
467
|
+
```typescript
|
|
468
|
+
ws.setVariable('username', 'John');
|
|
469
|
+
ws.setVariable('premium', true);
|
|
470
|
+
|
|
471
|
+
const username = ws.getVariable<string>('username');
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
### 11. Tích hợp Express API
|
|
475
|
+
|
|
476
|
+
```typescript
|
|
477
|
+
app.post('/api/notify', (req, res) => {
|
|
478
|
+
const { userId, message } = req.body;
|
|
479
|
+
wsServer.toClients(userId).emitS('notification', { message });
|
|
480
|
+
res.json({ success: true });
|
|
481
|
+
});
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
## 💻 WebSocket Client
|
|
485
|
+
|
|
486
|
+
### Browser
|
|
487
|
+
|
|
488
|
+
```typescript
|
|
489
|
+
import { Client } from '@vutotoite/websocket';
|
|
490
|
+
|
|
491
|
+
const ws = Client.WebsocketBrowser.getInstance();
|
|
492
|
+
|
|
493
|
+
ws.connect('ws://localhost:8080?user_id=123', 3000);
|
|
494
|
+
|
|
495
|
+
ws.on('__connection_open', () => console.log('Đã kết nối'));
|
|
496
|
+
ws.on('__connection_close', (d) => console.log('Mất kết nối', d));
|
|
497
|
+
ws.on('__connection_error', (e) => console.error('Lỗi', e));
|
|
498
|
+
|
|
499
|
+
ws.on('new_message', (data) => console.log('Tin nhắn:', data));
|
|
500
|
+
|
|
501
|
+
ws.emit('send_message', { room_id: 'general', message: 'Hello!' });
|
|
502
|
+
|
|
503
|
+
ws.disconnect();
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
### Node.js
|
|
507
|
+
|
|
508
|
+
```typescript
|
|
509
|
+
import { Client } from '@vutotoite/websocket';
|
|
510
|
+
|
|
511
|
+
const ws = Client.WebsocketNode.getInstance();
|
|
512
|
+
|
|
513
|
+
ws.connect('ws://localhost:8080?user_id=456', 3000, {
|
|
514
|
+
headers: { Authorization: 'Bearer token' }
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
ws.on('message', (data) => console.log(data));
|
|
518
|
+
ws.emit('chat', { room: 'tech', message: 'Hi from Node' });
|
|
519
|
+
|
|
520
|
+
ws.setupHeartbeat(30000); // ping mỗi 30s
|
|
521
|
+
ws.disconnect(false, 1000, 'Client closing');
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
### Event handlers nâng cao
|
|
525
|
+
|
|
526
|
+
```typescript
|
|
527
|
+
ws.once('welcome', (data) => console.log(data));
|
|
528
|
+
|
|
529
|
+
const handler = (d) => console.log(d);
|
|
530
|
+
ws.on('message', handler);
|
|
531
|
+
ws.off('message', handler); // hủy 1 handler
|
|
532
|
+
ws.off('message'); // hủy tất cả handler của event
|
|
533
|
+
ws.clearAllHandlers();
|
|
534
|
+
ws.getRegisteredEvents();
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
### Message queue
|
|
538
|
+
|
|
539
|
+
```typescript
|
|
540
|
+
ws.setMaxQueueSize(200);
|
|
541
|
+
ws.getQueueSize();
|
|
542
|
+
ws.isConnected();
|
|
543
|
+
ws.getReadyState(); // 0 CONNECTING, 1 OPEN, 2 CLOSING, 3 CLOSED
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
## 🔧 Codec tùy chỉnh
|
|
547
|
+
|
|
548
|
+
Mặc định dùng JSON `{ event, data }`. Có thể thay bằng codec riêng:
|
|
549
|
+
|
|
550
|
+
```typescript
|
|
551
|
+
import { Server } from '@vutotoite/websocket';
|
|
552
|
+
|
|
553
|
+
const myCodec: Server.Codec = {
|
|
554
|
+
encode: (msg) => JSON.stringify([msg.event, msg.data]),
|
|
555
|
+
decode: (raw) => {
|
|
556
|
+
const [event, data] = JSON.parse(raw);
|
|
557
|
+
return { event, data };
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
Server.WebsocketServer.init({ ws: { noServer: true }, codec: myCodec });
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
## 📖 API Reference
|
|
565
|
+
|
|
566
|
+
### `WebsocketServer`
|
|
567
|
+
|
|
568
|
+
| Method | Tham số | Mô tả |
|
|
569
|
+
|--------|---------|-------|
|
|
570
|
+
| `init()` | `opts: ServerInitOptions` | Khởi tạo (singleton) |
|
|
571
|
+
| `getInstance()` | - | Lấy instance |
|
|
572
|
+
| `onInstanceInit()` | `cb: (server) => void` | Callback chạy sau init |
|
|
573
|
+
| `attachServer()` | `httpServer: http.Server` | Gắn vào HTTP server |
|
|
574
|
+
| `setAuth()` | `auth: (req, query) => any` | Xác thực; throw để từ chối |
|
|
575
|
+
| `connected()` | `options: ConnectedOptions` | Đăng ký handler kết nối |
|
|
576
|
+
| `toClients()` | `...ids: string[]` | Target theo client id (xuyên node) |
|
|
577
|
+
| `toRooms()` | `...ids: string[]` | Target theo room (xuyên node) |
|
|
578
|
+
| `toAll()` | - | Target tất cả (xuyên node) |
|
|
579
|
+
| `filter()` | `cb: (conn) => boolean` | Lọc client **local** |
|
|
580
|
+
| `isExistRoom()` | `roomId: string` | Kiểm tra room (local) |
|
|
581
|
+
| `close()` | - | Đóng có trật tự: client 1001, dọn adapter, chờ ws đóng |
|
|
582
|
+
| `enableGracefulShutdown()` | `signals?: NodeJS.Signals[]` | Hook SIGTERM/SIGINT → tự `close()`; trả hàm hủy |
|
|
583
|
+
|
|
584
|
+
`ServerInitOptions`: `{ ws: ws.ServerOptions; adapter?: Adapter; codec?: Codec }`
|
|
585
|
+
|
|
586
|
+
### `SocketConnection`
|
|
587
|
+
|
|
588
|
+
| Method | Mô tả |
|
|
589
|
+
|--------|-------|
|
|
590
|
+
| `setId(id)` / `getId()` | Đặt / lấy id client |
|
|
591
|
+
| `onS(event, ...callbacks)` | Lắng nghe với middleware chain |
|
|
592
|
+
| `emitS(event, data?)` | Gửi tới chính client này |
|
|
593
|
+
| `join(room)` / `leave(room)` | Tham gia / rời phòng |
|
|
594
|
+
| `getRooms()` | Danh sách phòng |
|
|
595
|
+
| `setVariable(k, v)` / `getVariable(k)` | Lưu / lấy biến |
|
|
596
|
+
| `getQuery()` / `getAuthData()` | Lấy query / dữ liệu auth |
|
|
597
|
+
| `ping()` / `getAlive()` | Ping / trạng thái sống |
|
|
598
|
+
| `close(code?, reason?)` | Đóng kết nối |
|
|
599
|
+
|
|
600
|
+
### `SocketLink`
|
|
601
|
+
|
|
602
|
+
| Method | Mô tả |
|
|
603
|
+
|--------|-------|
|
|
604
|
+
| `emitS(event, data?)` | Gửi tới tập target đã chọn |
|
|
605
|
+
|
|
606
|
+
### `Adapter`
|
|
607
|
+
|
|
608
|
+
Implement interface này để tạo backend định tuyến tùy chỉnh. Có sẵn `MemoryAdapter` và `NatsAdapter`.
|
|
609
|
+
|
|
610
|
+
`new NatsAdapter(natsConnection, prefix?)` — `natsConnection` là connection NATS đã thiết lập, `prefix` mặc định `"ws"`.
|
|
611
|
+
|
|
612
|
+
### Client (`WebsocketBrowser` / `WebsocketNode`)
|
|
613
|
+
|
|
614
|
+
| Method | Mô tả |
|
|
615
|
+
|--------|-------|
|
|
616
|
+
| `getInstance()` | Lấy instance (singleton) |
|
|
617
|
+
| `connect(url, reconnectInterval?, options?)` | Kết nối (`options` chỉ Node) |
|
|
618
|
+
| `disconnect(shouldReconnect?, code?, reason?)` | Ngắt kết nối |
|
|
619
|
+
| `emit(event, data?)` | Gửi event |
|
|
620
|
+
| `on/once/off(event, handler?)` | Quản lý handler |
|
|
621
|
+
| `isConnected()` / `getReadyState()` | Trạng thái |
|
|
622
|
+
| `setMaxQueueSize(n)` / `getQueueSize()` | Queue |
|
|
623
|
+
| `clearAllHandlers()` / `getRegisteredEvents()` | Handlers |
|
|
624
|
+
|
|
625
|
+
**Chỉ `WebsocketNode`:** `ping(data?)`, `setupHeartbeat(interval?)`, `getWebSocketInstance()`.
|
|
626
|
+
|
|
627
|
+
## ⚠️ Breaking changes (v1 → v2)
|
|
628
|
+
|
|
629
|
+
- `init()` đổi signature: `init({ ws: ServerOptions, adapter?, codec? })` thay vì `init(ServerOptions, callback?)`.
|
|
630
|
+
- `WebsocketServer` không còn kế thừa `ws.WebSocketServer` (chuyển sang composition).
|
|
631
|
+
- `SocketLink` chỉ còn `emitS` (bỏ `join`/`leave`/`close` hàng loạt — không an toàn khi scale ngang).
|
|
632
|
+
- Client export đổi tên: `WebsocketBrowser` / `WebsocketNode` (thay cho `WsBrowserClient` / `WsNodeClient`).
|
|
633
|
+
- Middleware chain trong `onS` nay **dừng khi gặp lỗi** (trước đây vẫn chạy tiếp).
|
|
634
|
+
|
|
635
|
+
## 🧪 Test
|
|
636
|
+
|
|
637
|
+
```bash
|
|
638
|
+
npm test
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
## 📝 License
|
|
642
|
+
|
|
643
|
+
MIT
|
|
644
|
+
|
|
645
|
+
## 👨💻 Tác giả
|
|
646
|
+
|
|
647
|
+
maxsida <maxsida.dev@gmail.com>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { BaseClient } from "../core/base-client.js";
|
|
2
|
+
/** WebSocket client cho trình duyệt. */
|
|
3
|
+
export declare class WebsocketBrowser extends BaseClient {
|
|
4
|
+
private static instance;
|
|
5
|
+
private socket;
|
|
6
|
+
static getInstance(): WebsocketBrowser;
|
|
7
|
+
protected openSocket(): void;
|
|
8
|
+
protected sendRaw(raw: string): boolean;
|
|
9
|
+
protected closeSocket(): void;
|
|
10
|
+
isConnected(): boolean;
|
|
11
|
+
getReadyState(): number;
|
|
12
|
+
}
|