@seip/blue-bird 1.0.2 → 1.1.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/core/ws.js CHANGED
@@ -1,210 +1,210 @@
1
- import { WebSocketServer, WebSocket } from "ws";
2
- import { getRedisClient } from "./cache.js";
3
- import Auth from "./auth.js";
4
-
5
- /**
6
- * High-performance WebSocketManager providing real-time channels, room management,
7
- * heartbeat ping/pong, Auth token verification, and Redis Pub/Sub multi-process cluster scaling.
8
- */
9
- class WebSocketManager {
10
- /**
11
- * Initializes the WebSocket manager attached to an HTTP server.
12
- * @param {import('http').Server} server - Express HTTP server instance.
13
- * @param {Object} [options={}] - Configuration options.
14
- * @param {string} [options.path="/ws"] - WebSocket endpoint route.
15
- * @param {boolean} [options.auth=false] - Require valid Auth JWT token on connection.
16
- */
17
- constructor(server, options = {}) {
18
- this.server = server;
19
- this.path = options.path || "/ws";
20
- this.requireAuth = options.auth ?? false;
21
- this.rooms = new Map();
22
- this.clients = new Set();
23
- this.connectionHandler = null;
24
- this.redisPublisher = null;
25
- this.redisSubscriber = null;
26
-
27
- this.wss = new WebSocketServer({ noServer: true });
28
-
29
- this._setupUpgrade();
30
- this._setupHeartbeat();
31
- this._setupRedisPubSub();
32
- }
33
-
34
- /**
35
- * Attaches the HTTP Upgrade listener to the Express HTTP server instance.
36
- * @private
37
- */
38
- _setupUpgrade() {
39
- this.server.on("upgrade", async (request, socket, head) => {
40
- const urlObj = new URL(request.url, `http://${request.headers.host || "localhost"}`);
41
- if (urlObj.pathname !== this.path) {
42
- return;
43
- }
44
-
45
- if (this.requireAuth) {
46
- const cookieHeader = request.headers.cookie || "";
47
- const cookies = {};
48
- cookieHeader.split(";").forEach((c) => {
49
- const parts = c.trim().split("=");
50
- if (parts[0]) cookies[parts[0]] = decodeURIComponent(parts[1] || "");
51
- });
52
-
53
- const token = cookies.auth || urlObj.searchParams.get("token") || request.headers.authorization?.split(" ")[1];
54
- const user = token ? Auth.verifyToken(token) : null;
55
-
56
- if (!user) {
57
- socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
58
- socket.destroy();
59
- return;
60
- }
61
- request.user = user;
62
- }
63
-
64
- this.wss.handleUpgrade(request, socket, head, (ws) => {
65
- this.wss.emit("connection", ws, request);
66
- });
67
- });
68
-
69
- this.wss.on("connection", (ws, req) => {
70
- ws.isAlive = true;
71
- ws.user = req.user || null;
72
- ws.rooms = new Set();
73
- this.clients.add(ws);
74
-
75
- ws.on("pong", () => {
76
- ws.isAlive = true;
77
- });
78
-
79
- ws.on("close", () => {
80
- this.clients.delete(ws);
81
- ws.rooms.forEach((room) => this.leave(room, ws));
82
- });
83
-
84
- ws.on("error", () => {
85
- this.clients.delete(ws);
86
- });
87
-
88
- ws.join = (room) => this.join(room, ws);
89
- ws.leave = (room) => this.leave(room, ws);
90
- ws.sendJSON = (data) => {
91
- if (ws.readyState === WebSocket.OPEN) {
92
- ws.send(JSON.stringify(data));
93
- }
94
- };
95
-
96
- if (this.connectionHandler) {
97
- this.connectionHandler(ws, req);
98
- }
99
- });
100
- }
101
-
102
- /**
103
- * Registers a connection callback handler.
104
- * @param {Function} handler - Callback function: (ws, req) => {}
105
- */
106
- onConnection(handler) {
107
- this.connectionHandler = handler;
108
- }
109
-
110
- /**
111
- * Subscribes a WebSocket connection to a room.
112
- * @param {string} room - Room identifier.
113
- * @param {WebSocket} ws - Target WebSocket instance.
114
- */
115
- join(room, ws) {
116
- if (!this.rooms.has(room)) {
117
- this.rooms.set(room, new Set());
118
- }
119
- this.rooms.get(room).add(ws);
120
- ws.rooms.add(room);
121
- }
122
-
123
- /**
124
- * Unsubscribes a WebSocket connection from a room.
125
- * @param {string} room - Room identifier.
126
- * @param {WebSocket} ws - Target WebSocket instance.
127
- */
128
- leave(room, ws) {
129
- if (this.rooms.has(room)) {
130
- this.rooms.get(room).delete(ws);
131
- if (this.rooms.get(room).size === 0) {
132
- this.rooms.delete(room);
133
- }
134
- }
135
- ws.rooms.delete(room);
136
- }
137
-
138
- /**
139
- * Broadcasts a JSON payload or string to all connected clients (or specific room).
140
- * Automatically synchronizes across PM2 cluster workers via Redis Pub/Sub if Redis is active.
141
- * @param {any} data - Data to send.
142
- * @param {string} [room=null] - Optional target room.
143
- */
144
- broadcast(data, room = null) {
145
- const payload = typeof data === "string" ? data : JSON.stringify(data);
146
-
147
- this._sendLocal(payload, room);
148
-
149
- if (this.redisPublisher && this.redisPublisher.isOpen) {
150
- this.redisPublisher.publish("bluebird:ws:broadcast", JSON.stringify({ room, payload })).catch(() => {});
151
- }
152
- }
153
-
154
- /**
155
- * Transmits payload to local WebSocket connections.
156
- * @private
157
- */
158
- _sendLocal(payload, room = null) {
159
- const targetSockets = room && this.rooms.has(room) ? this.rooms.get(room) : this.clients;
160
- targetSockets.forEach((client) => {
161
- if (client.readyState === WebSocket.OPEN) {
162
- client.send(payload);
163
- }
164
- });
165
- }
166
-
167
- /**
168
- * Sets up 30s ping/pong heartbeat to clear dead TCP sockets.
169
- * @private
170
- */
171
- _setupHeartbeat() {
172
- this.heartbeatInterval = setInterval(() => {
173
- this.clients.forEach((ws) => {
174
- if (ws.isAlive === false) {
175
- this.clients.delete(ws);
176
- return ws.terminate();
177
- }
178
- ws.isAlive = false;
179
- ws.ping();
180
- });
181
- }, 30000);
182
- this.heartbeatInterval.unref();
183
- }
184
-
185
- /**
186
- * Initializes Redis Pub/Sub channels for multi-process PM2 cluster synchronization.
187
- * @private
188
- */
189
- async _setupRedisPubSub() {
190
- try {
191
- const redisClient = getRedisClient();
192
- if (!redisClient) return;
193
-
194
- this.redisPublisher = redisClient.duplicate();
195
- this.redisSubscriber = redisClient.duplicate();
196
-
197
- await this.redisPublisher.connect();
198
- await this.redisSubscriber.connect();
199
-
200
- await this.redisSubscriber.subscribe("bluebird:ws:broadcast", (message) => {
201
- try {
202
- const { room, payload } = JSON.parse(message);
203
- this._sendLocal(payload, room);
204
- } catch {}
205
- });
206
- } catch {}
207
- }
208
- }
209
-
210
- export default WebSocketManager;
1
+ import { WebSocketServer, WebSocket } from "ws";
2
+ import { getRedisClient } from "./cache.js";
3
+ import Auth from "./auth.js";
4
+
5
+ /**
6
+ * High-performance WebSocketManager providing real-time channels, room management,
7
+ * heartbeat ping/pong, Auth token verification, and Redis Pub/Sub multi-process cluster scaling.
8
+ */
9
+ class WebSocketManager {
10
+ /**
11
+ * Initializes the WebSocket manager attached to an HTTP server.
12
+ * @param {import('http').Server} server - Express HTTP server instance.
13
+ * @param {Object} [options={}] - Configuration options.
14
+ * @param {string} [options.path="/ws"] - WebSocket endpoint route.
15
+ * @param {boolean} [options.auth=false] - Require valid Auth JWT token on connection.
16
+ */
17
+ constructor(server, options = {}) {
18
+ this.server = server;
19
+ this.path = options.path || "/ws";
20
+ this.requireAuth = options.auth ?? false;
21
+ this.rooms = new Map();
22
+ this.clients = new Set();
23
+ this.connectionHandler = null;
24
+ this.redisPublisher = null;
25
+ this.redisSubscriber = null;
26
+
27
+ this.wss = new WebSocketServer({ noServer: true });
28
+
29
+ this._setupUpgrade();
30
+ this._setupHeartbeat();
31
+ this._setupRedisPubSub();
32
+ }
33
+
34
+ /**
35
+ * Attaches the HTTP Upgrade listener to the Express HTTP server instance.
36
+ * @private
37
+ */
38
+ _setupUpgrade() {
39
+ this.server.on("upgrade", async (request, socket, head) => {
40
+ const urlObj = new URL(request.url, `http://${request.headers.host || "localhost"}`);
41
+ if (urlObj.pathname !== this.path) {
42
+ return;
43
+ }
44
+
45
+ if (this.requireAuth) {
46
+ const cookieHeader = request.headers.cookie || "";
47
+ const cookies = {};
48
+ cookieHeader.split(";").forEach((c) => {
49
+ const parts = c.trim().split("=");
50
+ if (parts[0]) cookies[parts[0]] = decodeURIComponent(parts[1] || "");
51
+ });
52
+
53
+ const token = cookies.auth || urlObj.searchParams.get("token") || request.headers.authorization?.split(" ")[1];
54
+ const user = token ? Auth.verifyToken(token) : null;
55
+
56
+ if (!user) {
57
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
58
+ socket.destroy();
59
+ return;
60
+ }
61
+ request.user = user;
62
+ }
63
+
64
+ this.wss.handleUpgrade(request, socket, head, (ws) => {
65
+ this.wss.emit("connection", ws, request);
66
+ });
67
+ });
68
+
69
+ this.wss.on("connection", (ws, req) => {
70
+ ws.isAlive = true;
71
+ ws.user = req.user || null;
72
+ ws.rooms = new Set();
73
+ this.clients.add(ws);
74
+
75
+ ws.on("pong", () => {
76
+ ws.isAlive = true;
77
+ });
78
+
79
+ ws.on("close", () => {
80
+ this.clients.delete(ws);
81
+ ws.rooms.forEach((room) => this.leave(room, ws));
82
+ });
83
+
84
+ ws.on("error", () => {
85
+ this.clients.delete(ws);
86
+ });
87
+
88
+ ws.join = (room) => this.join(room, ws);
89
+ ws.leave = (room) => this.leave(room, ws);
90
+ ws.sendJSON = (data) => {
91
+ if (ws.readyState === WebSocket.OPEN) {
92
+ ws.send(JSON.stringify(data));
93
+ }
94
+ };
95
+
96
+ if (this.connectionHandler) {
97
+ this.connectionHandler(ws, req);
98
+ }
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Registers a connection callback handler.
104
+ * @param {Function} handler - Callback function: (ws, req) => {}
105
+ */
106
+ onConnection(handler) {
107
+ this.connectionHandler = handler;
108
+ }
109
+
110
+ /**
111
+ * Subscribes a WebSocket connection to a room.
112
+ * @param {string} room - Room identifier.
113
+ * @param {WebSocket} ws - Target WebSocket instance.
114
+ */
115
+ join(room, ws) {
116
+ if (!this.rooms.has(room)) {
117
+ this.rooms.set(room, new Set());
118
+ }
119
+ this.rooms.get(room).add(ws);
120
+ ws.rooms.add(room);
121
+ }
122
+
123
+ /**
124
+ * Unsubscribes a WebSocket connection from a room.
125
+ * @param {string} room - Room identifier.
126
+ * @param {WebSocket} ws - Target WebSocket instance.
127
+ */
128
+ leave(room, ws) {
129
+ if (this.rooms.has(room)) {
130
+ this.rooms.get(room).delete(ws);
131
+ if (this.rooms.get(room).size === 0) {
132
+ this.rooms.delete(room);
133
+ }
134
+ }
135
+ ws.rooms.delete(room);
136
+ }
137
+
138
+ /**
139
+ * Broadcasts a JSON payload or string to all connected clients (or specific room).
140
+ * Automatically synchronizes across PM2 cluster workers via Redis Pub/Sub if Redis is active.
141
+ * @param {any} data - Data to send.
142
+ * @param {string} [room=null] - Optional target room.
143
+ */
144
+ broadcast(data, room = null) {
145
+ const payload = typeof data === "string" ? data : JSON.stringify(data);
146
+
147
+ this._sendLocal(payload, room);
148
+
149
+ if (this.redisPublisher && this.redisPublisher.isOpen) {
150
+ this.redisPublisher.publish("bluebird:ws:broadcast", JSON.stringify({ room, payload })).catch(() => {});
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Transmits payload to local WebSocket connections.
156
+ * @private
157
+ */
158
+ _sendLocal(payload, room = null) {
159
+ const targetSockets = room && this.rooms.has(room) ? this.rooms.get(room) : this.clients;
160
+ targetSockets.forEach((client) => {
161
+ if (client.readyState === WebSocket.OPEN) {
162
+ client.send(payload);
163
+ }
164
+ });
165
+ }
166
+
167
+ /**
168
+ * Sets up 30s ping/pong heartbeat to clear dead TCP sockets.
169
+ * @private
170
+ */
171
+ _setupHeartbeat() {
172
+ this.heartbeatInterval = setInterval(() => {
173
+ this.clients.forEach((ws) => {
174
+ if (ws.isAlive === false) {
175
+ this.clients.delete(ws);
176
+ return ws.terminate();
177
+ }
178
+ ws.isAlive = false;
179
+ ws.ping();
180
+ });
181
+ }, 30000);
182
+ this.heartbeatInterval.unref();
183
+ }
184
+
185
+ /**
186
+ * Initializes Redis Pub/Sub channels for multi-process PM2 cluster synchronization.
187
+ * @private
188
+ */
189
+ async _setupRedisPubSub() {
190
+ try {
191
+ const redisClient = getRedisClient();
192
+ if (!redisClient) return;
193
+
194
+ this.redisPublisher = redisClient.duplicate();
195
+ this.redisSubscriber = redisClient.duplicate();
196
+
197
+ await this.redisPublisher.connect();
198
+ await this.redisSubscriber.connect();
199
+
200
+ await this.redisSubscriber.subscribe("bluebird:ws:broadcast", (message) => {
201
+ try {
202
+ const { room, payload } = JSON.parse(message);
203
+ this._sendLocal(payload, room);
204
+ } catch {}
205
+ });
206
+ } catch {}
207
+ }
208
+ }
209
+
210
+ export default WebSocketManager;