topsyde-utils 1.0.204 → 1.0.206
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/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/server/bun/websocket/Channel.d.ts +25 -3
- package/dist/server/bun/websocket/Channel.js +80 -26
- package/dist/server/bun/websocket/Channel.js.map +1 -1
- package/dist/server/bun/websocket/Client.d.ts +34 -1
- package/dist/server/bun/websocket/Client.js +95 -18
- package/dist/server/bun/websocket/Client.js.map +1 -1
- package/dist/server/bun/websocket/Message.d.ts +6 -10
- package/dist/server/bun/websocket/Message.js +31 -32
- package/dist/server/bun/websocket/Message.js.map +1 -1
- package/dist/server/bun/websocket/Websocket.d.ts +35 -4
- package/dist/server/bun/websocket/Websocket.js +71 -12
- package/dist/server/bun/websocket/Websocket.js.map +1 -1
- package/dist/server/bun/websocket/index.d.ts +1 -1
- package/dist/server/bun/websocket/index.js +1 -1
- package/dist/server/bun/websocket/index.js.map +1 -1
- package/dist/server/bun/websocket/websocket.enums.d.ts +6 -0
- package/dist/server/bun/websocket/websocket.enums.js +7 -0
- package/dist/server/bun/websocket/websocket.enums.js.map +1 -1
- package/dist/server/bun/websocket/websocket.types.d.ts +60 -3
- package/dist/server/bun/websocket/websocket.types.js.map +1 -1
- package/dist/utils/BaseEntity.d.ts +4 -0
- package/dist/utils/BaseEntity.js +4 -0
- package/dist/utils/BaseEntity.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/app.test.ts +1 -1
- package/src/__tests__/singleton.test.ts +6 -4
- package/src/index.ts +4 -2
- package/src/server/bun/websocket/Channel.ts +89 -36
- package/src/server/bun/websocket/Client.ts +109 -19
- package/src/server/bun/websocket/ISSUES.md +1175 -0
- package/src/server/bun/websocket/Message.ts +36 -49
- package/src/server/bun/websocket/Websocket.ts +72 -12
- package/src/server/bun/websocket/index.ts +1 -1
- package/src/server/bun/websocket/websocket.enums.ts +7 -0
- package/src/server/bun/websocket/websocket.types.ts +58 -3
- package/src/utils/BaseEntity.ts +8 -1
@@ -0,0 +1,1175 @@
|
|
1
|
+
# WebSocket Framework Issues & Improvements
|
2
|
+
|
3
|
+
This document outlines identified issues in the WebSocket framework implementation and proposed solutions.
|
4
|
+
|
5
|
+
---
|
6
|
+
|
7
|
+
## Critical Issues
|
8
|
+
|
9
|
+
### Issue #3: Circular Dependency - Channel ↔ Client Interaction
|
10
|
+
|
11
|
+
**Location:** [Channel.ts:87-91](Channel.ts#L87-L91), [Client.ts:62-73](Client.ts#L62-L73)
|
12
|
+
|
13
|
+
**Problem:**
|
14
|
+
The interaction between `Channel.addMember()` and `Client.joinChannel()` creates a circular dependency that's fragile and error-prone:
|
15
|
+
|
16
|
+
1. `channel.addMember(client)` calls `client.joinChannel(this)`
|
17
|
+
2. `client.joinChannel(channel)` subscribes to the channel and sends a message
|
18
|
+
3. If any step fails (subscription, message send), the channel state and client state become inconsistent
|
19
|
+
|
20
|
+
**Current Flow:**
|
21
|
+
```typescript
|
22
|
+
// Channel.ts
|
23
|
+
public addMember(client: I_WebsocketClient) {
|
24
|
+
if (!this.canAddMember()) return false;
|
25
|
+
this.members.set(client.id, client); // State changed
|
26
|
+
client.joinChannel(this); // But this could fail
|
27
|
+
return client;
|
28
|
+
}
|
29
|
+
|
30
|
+
// Client.ts
|
31
|
+
public joinChannel(channel: I_WebsocketChannel, send: boolean = true) {
|
32
|
+
this.subscribe(channel_id); // Could fail
|
33
|
+
this.channels.set(channel_id, channel);
|
34
|
+
if (send) this.send({...}); // Could fail
|
35
|
+
}
|
36
|
+
```
|
37
|
+
|
38
|
+
**Issues:**
|
39
|
+
- No rollback if `joinChannel()` fails
|
40
|
+
- No error propagation
|
41
|
+
- State becomes inconsistent between channel and client
|
42
|
+
- Tight coupling makes testing difficult
|
43
|
+
|
44
|
+
**Solution - Decoupled Responsibility (Recommended):**
|
45
|
+
```typescript
|
46
|
+
// Channel.ts - Channel only manages membership
|
47
|
+
public addMember(client: I_WebsocketClient): boolean {
|
48
|
+
if (!this.canAddMember()) {
|
49
|
+
return false;
|
50
|
+
}
|
51
|
+
|
52
|
+
if (this.members.has(client.id)) {
|
53
|
+
return false; // Already a member
|
54
|
+
}
|
55
|
+
|
56
|
+
this.members.set(client.id, client);
|
57
|
+
return true;
|
58
|
+
}
|
59
|
+
|
60
|
+
// Add internal method for rollback
|
61
|
+
public removeMemberInternal(client: I_WebsocketClient): void {
|
62
|
+
this.members.delete(client.id);
|
63
|
+
}
|
64
|
+
|
65
|
+
// Client.ts - Client manages its own channel list
|
66
|
+
public joinChannel(channel: I_WebsocketChannel): boolean {
|
67
|
+
const channel_id = channel.getId();
|
68
|
+
|
69
|
+
if (this.channels.has(channel_id)) {
|
70
|
+
return false; // Already joined
|
71
|
+
}
|
72
|
+
|
73
|
+
// Try to add to channel first
|
74
|
+
if (!channel.addMember(this)) {
|
75
|
+
return false; // Channel full or other issue
|
76
|
+
}
|
77
|
+
|
78
|
+
try {
|
79
|
+
this.subscribe(channel_id);
|
80
|
+
this.channels.set(channel_id, channel);
|
81
|
+
|
82
|
+
this.send({
|
83
|
+
type: E_WebsocketMessageType.CLIENT_JOIN_CHANNEL,
|
84
|
+
content: { message: "Welcome to the channel" },
|
85
|
+
channel: channel_id,
|
86
|
+
client: this.whoami(),
|
87
|
+
});
|
88
|
+
|
89
|
+
return true;
|
90
|
+
} catch (error) {
|
91
|
+
// Rollback channel membership
|
92
|
+
channel.removeMemberInternal(this);
|
93
|
+
throw error;
|
94
|
+
}
|
95
|
+
}
|
96
|
+
|
97
|
+
// Websocket.ts - Coordinator handles high-level flow
|
98
|
+
private clientConnected = (ws: ServerWebSocket<WebsocketEntityData>) => {
|
99
|
+
const global = this._channels.get("global");
|
100
|
+
if (!global) throw new Error("Global channel not found");
|
101
|
+
|
102
|
+
const client = Websocket.CreateClient({ id: ws.data.id, ws: ws, name: ws.data.name });
|
103
|
+
this._clients.set(client.id, client);
|
104
|
+
|
105
|
+
// Client handles its own joining logic
|
106
|
+
if (!client.joinChannel(global)) {
|
107
|
+
Lib.Warn(`Failed to add client ${client.id} to global channel`);
|
108
|
+
}
|
109
|
+
};
|
110
|
+
```
|
111
|
+
|
112
|
+
---
|
113
|
+
|
114
|
+
### Issue #5: Broadcast Optimization Logic Flaw
|
115
|
+
|
116
|
+
**Location:** [Channel.ts:50-62](Channel.ts#L50-L62)
|
117
|
+
|
118
|
+
**Problem:**
|
119
|
+
The broadcast method has a "smart" optimization that switches between pub/sub and individual sends based on arbitrary thresholds:
|
120
|
+
|
121
|
+
```typescript
|
122
|
+
if (this.members.size > 10 && options.excludeClients.length > this.members.size / 3) {
|
123
|
+
// Send individually
|
124
|
+
const serializedMessage = this.message.serialize(output);
|
125
|
+
for (const [clientId, client] of this.members) {
|
126
|
+
if (!options.excludeClients.includes(clientId)) {
|
127
|
+
client.ws.send(serializedMessage);
|
128
|
+
}
|
129
|
+
}
|
130
|
+
return;
|
131
|
+
}
|
132
|
+
// Otherwise use pub/sub
|
133
|
+
this.ws.server.publish(this.id, this.message.serialize(output));
|
134
|
+
```
|
135
|
+
|
136
|
+
**Issues:**
|
137
|
+
1. **Arbitrary thresholds** - Why 10 members? Why 33%? No justification
|
138
|
+
2. **Performance assumptions** - Individual sends may not be faster than pub/sub + client filtering
|
139
|
+
3. **Inconsistent behavior** - Sometimes uses pub/sub, sometimes doesn't
|
140
|
+
4. **CRITICAL BUG: Excluded clients still receive via pub/sub** - The pub/sub path ignores `excludeClients` entirely!
|
141
|
+
5. **O(n²) complexity** - `excludeClients.includes()` is O(n) inside a loop
|
142
|
+
|
143
|
+
**Most Critical:** When you use the pub/sub path, excluded clients STILL receive the message.
|
144
|
+
|
145
|
+
**Solution - Always Use Individual Sends When Filtering:**
|
146
|
+
```typescript
|
147
|
+
public broadcast(message: WebsocketMessage | string, options?: BroadcastOptions) {
|
148
|
+
if (Guards.IsString(message)) {
|
149
|
+
message = { type: "message", content: { message } };
|
150
|
+
}
|
151
|
+
|
152
|
+
const output = this.message.create(message, { ...options, channel: this.id });
|
153
|
+
|
154
|
+
if (options?.includeMetadata) {
|
155
|
+
output.metadata = options.includeMetadata === true
|
156
|
+
? this.getMetadata()
|
157
|
+
: this.getFilteredMetadata(options.includeMetadata);
|
158
|
+
}
|
159
|
+
|
160
|
+
const serializedMessage = this.message.serialize(output);
|
161
|
+
|
162
|
+
// If we need to exclude clients, send individually
|
163
|
+
if (options?.excludeClients && options.excludeClients.length > 0) {
|
164
|
+
const excludeSet = new Set(options.excludeClients); // O(1) lookup
|
165
|
+
|
166
|
+
for (const [clientId, client] of this.members) {
|
167
|
+
if (!excludeSet.has(clientId)) {
|
168
|
+
try {
|
169
|
+
client.ws.send(serializedMessage);
|
170
|
+
} catch (error) {
|
171
|
+
Lib.Warn(`Failed to send to client ${clientId}:`, error);
|
172
|
+
}
|
173
|
+
}
|
174
|
+
}
|
175
|
+
return;
|
176
|
+
}
|
177
|
+
|
178
|
+
// Otherwise use pub/sub for everyone
|
179
|
+
this.ws.server.publish(this.id, serializedMessage);
|
180
|
+
}
|
181
|
+
```
|
182
|
+
|
183
|
+
**Alternative - Make Optimization Configurable:**
|
184
|
+
```typescript
|
185
|
+
// In Channel constructor or options
|
186
|
+
export type ChannelOptions = {
|
187
|
+
broadcastStrategy?: 'pubsub' | 'individual' | 'auto';
|
188
|
+
autoThreshold?: { minMembers: number; excludeRatio: number };
|
189
|
+
};
|
190
|
+
|
191
|
+
public broadcast(message: WebsocketMessage | string, options?: BroadcastOptions) {
|
192
|
+
// ... message preparation ...
|
193
|
+
|
194
|
+
const strategy = this.options?.broadcastStrategy ?? 'auto';
|
195
|
+
const shouldSendIndividually =
|
196
|
+
strategy === 'individual' ||
|
197
|
+
(options?.excludeClients && options.excludeClients.length > 0) ||
|
198
|
+
(strategy === 'auto' && this.shouldUseIndividualSends(options));
|
199
|
+
|
200
|
+
if (shouldSendIndividually) {
|
201
|
+
this.broadcastIndividual(serializedMessage, options?.excludeClients);
|
202
|
+
} else {
|
203
|
+
this.ws.server.publish(this.id, serializedMessage);
|
204
|
+
}
|
205
|
+
}
|
206
|
+
|
207
|
+
private shouldUseIndividualSends(options?: BroadcastOptions): boolean {
|
208
|
+
if (!options?.excludeClients || options.excludeClients.length === 0) {
|
209
|
+
return false;
|
210
|
+
}
|
211
|
+
|
212
|
+
const threshold = this.options?.autoThreshold ?? { minMembers: 50, excludeRatio: 0.5 };
|
213
|
+
return this.members.size >= threshold.minMembers &&
|
214
|
+
options.excludeClients.length > this.members.size * threshold.excludeRatio;
|
215
|
+
}
|
216
|
+
```
|
217
|
+
|
218
|
+
**Recommended:** Simple approach - always use individual sends when `excludeClients` exists.
|
219
|
+
|
220
|
+
---
|
221
|
+
|
222
|
+
### Issue #8: Wasteful Message Instance Per Channel
|
223
|
+
|
224
|
+
**Location:** [Channel.ts:21](Channel.ts#L21), [Channel.ts:30](Channel.ts#L30)
|
225
|
+
|
226
|
+
**Problem:**
|
227
|
+
Every channel creates its own `Message` instance:
|
228
|
+
```typescript
|
229
|
+
private message: Message;
|
230
|
+
|
231
|
+
constructor(...) {
|
232
|
+
this.message = new Message();
|
233
|
+
}
|
234
|
+
```
|
235
|
+
|
236
|
+
However, `Message` is completely stateless - it only has a template object that gets cloned:
|
237
|
+
```typescript
|
238
|
+
export default class Message {
|
239
|
+
private messageTemplate: WebsocketStructuredMessage<any>;
|
240
|
+
|
241
|
+
constructor() {
|
242
|
+
this.messageTemplate = { type: "", content: {}, channel: "", timestamp: "" };
|
243
|
+
}
|
244
|
+
}
|
245
|
+
```
|
246
|
+
|
247
|
+
**Issues:**
|
248
|
+
1. **Memory waste** - If you have 100 channels, you create 100 identical `Message` instances
|
249
|
+
2. **Unnecessary allocations** - Each instance allocates the same template object
|
250
|
+
3. **Inconsistent API** - `Message` has static methods (`Message.Create`) but channels use instances
|
251
|
+
|
252
|
+
**Solution - Use Static Methods Only:**
|
253
|
+
```typescript
|
254
|
+
// Message.ts - Make it a pure utility class
|
255
|
+
export default class Message {
|
256
|
+
// Make template static and shared
|
257
|
+
private static readonly MESSAGE_TEMPLATE: WebsocketStructuredMessage<any> = {
|
258
|
+
type: "",
|
259
|
+
content: {},
|
260
|
+
channel: "",
|
261
|
+
timestamp: ""
|
262
|
+
};
|
263
|
+
|
264
|
+
// Keep constructor private to prevent instantiation
|
265
|
+
private constructor() {}
|
266
|
+
|
267
|
+
public static Create(message: WebsocketMessage, options?: WebsocketMessageOptions): WebsocketStructuredMessage {
|
268
|
+
// Use static template
|
269
|
+
const output = Object.assign({}, Message.MESSAGE_TEMPLATE);
|
270
|
+
|
271
|
+
output.type = message.type;
|
272
|
+
output.channel = message.channel || options?.channel || "N/A";
|
273
|
+
|
274
|
+
if (typeof message.content === "string") {
|
275
|
+
output.content = { message: message.content };
|
276
|
+
} else if (typeof message.content === "object" && message.content !== null) {
|
277
|
+
output.content = { ...message.content };
|
278
|
+
} else {
|
279
|
+
output.content = {};
|
280
|
+
}
|
281
|
+
|
282
|
+
if (options) {
|
283
|
+
// Add data if provided
|
284
|
+
if (options.data !== undefined) {
|
285
|
+
if (typeof options.data === "object" && options.data !== null && !Array.isArray(options.data)) {
|
286
|
+
Object.assign(output.content, options.data);
|
287
|
+
} else {
|
288
|
+
output.content.data = options.data;
|
289
|
+
}
|
290
|
+
}
|
291
|
+
|
292
|
+
if (options.client && Guards.IsObject(options.client) && Guards.IsString(options.client.id, true)) {
|
293
|
+
output.client = {
|
294
|
+
id: options.client.id,
|
295
|
+
name: options.client.name,
|
296
|
+
};
|
297
|
+
}
|
298
|
+
|
299
|
+
if (options.includeMetadata !== false) output.metadata = options.metadata;
|
300
|
+
|
301
|
+
if (options.includeTimestamp !== false) {
|
302
|
+
output.timestamp = new Date().toISOString();
|
303
|
+
} else {
|
304
|
+
delete output.timestamp;
|
305
|
+
}
|
306
|
+
|
307
|
+
if (options.priority !== undefined) {
|
308
|
+
output.priority = options.priority;
|
309
|
+
}
|
310
|
+
|
311
|
+
if (options.expiresAt !== undefined) {
|
312
|
+
output.expiresAt = options.expiresAt;
|
313
|
+
}
|
314
|
+
|
315
|
+
if (options.customFields) {
|
316
|
+
Object.assign(output, options.customFields);
|
317
|
+
}
|
318
|
+
|
319
|
+
if (options.transform) {
|
320
|
+
return options.transform(output);
|
321
|
+
}
|
322
|
+
} else {
|
323
|
+
output.timestamp = new Date().toISOString();
|
324
|
+
}
|
325
|
+
|
326
|
+
return output;
|
327
|
+
}
|
328
|
+
|
329
|
+
public static Serialize<T = string>(message: WebsocketStructuredMessage, transform?: (message: WebsocketStructuredMessage) => T): string | T {
|
330
|
+
return transform ? transform(message) : JSON.stringify(message);
|
331
|
+
}
|
332
|
+
|
333
|
+
public static CreateWhisper(message: Omit<WebsocketMessage, "type">, options?: WebsocketMessageOptions) {
|
334
|
+
return Message.Create({ ...message, type: "whisper" }, options);
|
335
|
+
}
|
336
|
+
}
|
337
|
+
|
338
|
+
// Channel.ts - Remove instance field
|
339
|
+
export default class Channel<T extends Websocket = Websocket> implements I_WebsocketChannel<T> {
|
340
|
+
// Remove: private message: Message;
|
341
|
+
|
342
|
+
constructor(...) {
|
343
|
+
// Remove: this.message = new Message();
|
344
|
+
}
|
345
|
+
|
346
|
+
public broadcast(message: WebsocketMessage | string, options?: BroadcastOptions) {
|
347
|
+
if (Guards.IsString(message)) {
|
348
|
+
message = { type: "message", content: { message } };
|
349
|
+
}
|
350
|
+
|
351
|
+
// Use static method
|
352
|
+
const output = Message.Create(message, { ...options, channel: this.id });
|
353
|
+
|
354
|
+
if (options?.includeMetadata) {
|
355
|
+
output.metadata = options.includeMetadata === true
|
356
|
+
? this.getMetadata()
|
357
|
+
: this.getFilteredMetadata(options.includeMetadata);
|
358
|
+
}
|
359
|
+
|
360
|
+
this.ws.server.publish(this.id, Message.Serialize(output));
|
361
|
+
}
|
362
|
+
}
|
363
|
+
|
364
|
+
// Client.ts - Use static methods
|
365
|
+
public send(message: WebsocketStructuredMessage | string, options?: WebsocketMessageOptions): void {
|
366
|
+
if (Guards.IsString(message)) {
|
367
|
+
const msg: WebsocketMessage = {
|
368
|
+
type: "message",
|
369
|
+
content: { message },
|
370
|
+
};
|
371
|
+
message = Message.Create(msg, options);
|
372
|
+
}
|
373
|
+
this.ws.send(Message.Serialize({ client: this.whoami(), ...message }));
|
374
|
+
}
|
375
|
+
```
|
376
|
+
|
377
|
+
---
|
378
|
+
|
379
|
+
### Issue #10: Type Confusion - WebsocketStructuredMessage Includes Transport Options
|
380
|
+
|
381
|
+
**Location:** [websocket.types.ts:105](websocket.types.ts#L105)
|
382
|
+
|
383
|
+
**Problem:**
|
384
|
+
`WebsocketStructuredMessage` is defined as:
|
385
|
+
```typescript
|
386
|
+
export type WebsocketStructuredMessage<T extends Record<string, any> = Record<string, any>> =
|
387
|
+
WebsocketMessage<T> & WebsocketMessageOptions;
|
388
|
+
```
|
389
|
+
|
390
|
+
This means transport/processing options like `excludeClients`, `transform`, `includeTimestamp` become part of the message structure. This causes several issues:
|
391
|
+
|
392
|
+
1. **Transport options leak into wire format** - Options meant for server-side processing are sent to clients
|
393
|
+
2. **Type confusion** - Is `WebsocketStructuredMessage` a wire format or an API parameter?
|
394
|
+
3. **Security risk** - `excludeClients` array is sent over the wire (exposes client IDs)
|
395
|
+
4. **Bloated messages** - Transform functions, metadata flags, etc. are included in JSON
|
396
|
+
|
397
|
+
**Current behavior:**
|
398
|
+
```typescript
|
399
|
+
const message: WebsocketStructuredMessage = {
|
400
|
+
type: "message",
|
401
|
+
content: { text: "Hello" },
|
402
|
+
excludeClients: ["user-123", "user-456"], // ❌ Sent over wire!
|
403
|
+
transform: (msg) => msg, // ❌ Function in JSON!
|
404
|
+
includeMetadata: true, // ❌ Internal flag sent!
|
405
|
+
};
|
406
|
+
```
|
407
|
+
|
408
|
+
**Solution - Separate Wire Format from Processing Options:**
|
409
|
+
|
410
|
+
```typescript
|
411
|
+
// websocket.types.ts
|
412
|
+
|
413
|
+
/**
|
414
|
+
* Message structure sent over the wire to clients.
|
415
|
+
* This is the actual WebSocket payload format.
|
416
|
+
*/
|
417
|
+
export type WebsocketMessage<T extends Record<string, any> = Record<string, any>> = {
|
418
|
+
/** Message type identifier for client-side routing */
|
419
|
+
type: string;
|
420
|
+
|
421
|
+
/** Message payload */
|
422
|
+
content: T;
|
423
|
+
|
424
|
+
/** Channel ID where message originated */
|
425
|
+
channel?: string;
|
426
|
+
|
427
|
+
/** ISO timestamp when message was created */
|
428
|
+
timestamp?: string;
|
429
|
+
|
430
|
+
/** Client information (who sent this) */
|
431
|
+
client?: WebsocketEntityData;
|
432
|
+
|
433
|
+
/** Channel metadata (if included) */
|
434
|
+
metadata?: Record<string, string>;
|
435
|
+
|
436
|
+
/** Message priority for client-side processing */
|
437
|
+
priority?: number;
|
438
|
+
|
439
|
+
/** Expiration timestamp (milliseconds since epoch) */
|
440
|
+
expiresAt?: number;
|
441
|
+
|
442
|
+
/** Any additional custom fields */
|
443
|
+
[key: string]: any;
|
444
|
+
};
|
445
|
+
|
446
|
+
/**
|
447
|
+
* Options for message creation and broadcasting.
|
448
|
+
* These are NEVER sent over the wire - only used server-side.
|
449
|
+
*/
|
450
|
+
export type WebsocketMessageOptions = {
|
451
|
+
/** Additional data to merge into content */
|
452
|
+
data?: any;
|
453
|
+
|
454
|
+
/** Client information to include */
|
455
|
+
client?: Partial<WebsocketEntityData> & { [key: string]: any };
|
456
|
+
|
457
|
+
/** Channel metadata to include (true = all, array = specific keys) */
|
458
|
+
includeMetadata?: boolean | string[];
|
459
|
+
|
460
|
+
/** Client IDs to exclude from broadcast (server-side only) */
|
461
|
+
excludeClients?: string[];
|
462
|
+
|
463
|
+
/** Channel identifier */
|
464
|
+
channel?: string;
|
465
|
+
|
466
|
+
/** Include timestamp (default: true) */
|
467
|
+
includeTimestamp?: boolean;
|
468
|
+
|
469
|
+
/** Custom fields to add to message root */
|
470
|
+
customFields?: Record<string, any>;
|
471
|
+
|
472
|
+
/** Transform message before sending (server-side only) */
|
473
|
+
transform?: (message: WebsocketMessage) => WebsocketMessage;
|
474
|
+
|
475
|
+
/** Message priority */
|
476
|
+
priority?: number;
|
477
|
+
|
478
|
+
/** Message expiration time */
|
479
|
+
expiresAt?: number;
|
480
|
+
|
481
|
+
/** Channel metadata to include */
|
482
|
+
metadata?: Record<string, string>;
|
483
|
+
};
|
484
|
+
|
485
|
+
/**
|
486
|
+
* DEPRECATED: Use WebsocketMessage directly.
|
487
|
+
* This type will be removed in the next major version.
|
488
|
+
*/
|
489
|
+
export type WebsocketStructuredMessage<T extends Record<string, any> = Record<string, any>> = WebsocketMessage<T>;
|
490
|
+
|
491
|
+
export type BroadcastOptions = WebsocketMessageOptions & {
|
492
|
+
debug?: boolean;
|
493
|
+
};
|
494
|
+
```
|
495
|
+
|
496
|
+
**Update Message.Create() to return clean wire format:**
|
497
|
+
|
498
|
+
```typescript
|
499
|
+
// Message.ts
|
500
|
+
export default class Message {
|
501
|
+
public static Create(
|
502
|
+
message: Omit<WebsocketMessage, 'timestamp'>,
|
503
|
+
options?: WebsocketMessageOptions
|
504
|
+
): WebsocketMessage {
|
505
|
+
// Build clean wire format - no transport options included
|
506
|
+
const output: WebsocketMessage = {
|
507
|
+
type: message.type,
|
508
|
+
channel: message.channel || options?.channel || "N/A",
|
509
|
+
content: typeof message.content === 'string'
|
510
|
+
? { message: message.content }
|
511
|
+
: { ...message.content }
|
512
|
+
};
|
513
|
+
|
514
|
+
if (options) {
|
515
|
+
// Merge data into content
|
516
|
+
if (options.data !== undefined) {
|
517
|
+
if (typeof options.data === 'object' && !Array.isArray(options.data)) {
|
518
|
+
Object.assign(output.content, options.data);
|
519
|
+
} else {
|
520
|
+
output.content.data = options.data;
|
521
|
+
}
|
522
|
+
}
|
523
|
+
|
524
|
+
// Add client info (sanitized)
|
525
|
+
if (options.client?.id) {
|
526
|
+
output.client = {
|
527
|
+
id: options.client.id,
|
528
|
+
name: options.client.name || 'Unknown'
|
529
|
+
};
|
530
|
+
}
|
531
|
+
|
532
|
+
// Add metadata if requested
|
533
|
+
if (options.metadata) {
|
534
|
+
output.metadata = options.metadata;
|
535
|
+
}
|
536
|
+
|
537
|
+
// Add timestamp (default: true)
|
538
|
+
if (options.includeTimestamp !== false) {
|
539
|
+
output.timestamp = new Date().toISOString();
|
540
|
+
}
|
541
|
+
|
542
|
+
// Add priority if specified
|
543
|
+
if (options.priority !== undefined) {
|
544
|
+
output.priority = options.priority;
|
545
|
+
}
|
546
|
+
|
547
|
+
// Add expiration if specified
|
548
|
+
if (options.expiresAt !== undefined) {
|
549
|
+
output.expiresAt = options.expiresAt;
|
550
|
+
}
|
551
|
+
|
552
|
+
// Add custom fields
|
553
|
+
if (options.customFields) {
|
554
|
+
Object.assign(output, options.customFields);
|
555
|
+
}
|
556
|
+
|
557
|
+
// Apply transform last
|
558
|
+
if (options.transform) {
|
559
|
+
return options.transform(output);
|
560
|
+
}
|
561
|
+
} else {
|
562
|
+
output.timestamp = new Date().toISOString();
|
563
|
+
}
|
564
|
+
|
565
|
+
// Note: excludeClients, includeMetadata flags, etc. are NOT in output
|
566
|
+
return output;
|
567
|
+
}
|
568
|
+
}
|
569
|
+
```
|
570
|
+
|
571
|
+
**Migration Plan:**
|
572
|
+
1. Keep `WebsocketStructuredMessage` as alias to `WebsocketMessage` (deprecated)
|
573
|
+
2. Update all code to use `WebsocketMessage` directly
|
574
|
+
3. Remove `WebsocketStructuredMessage` in next major version
|
575
|
+
|
576
|
+
---
|
577
|
+
|
578
|
+
### Issue #4: Channel Limit Enforcement Issues
|
579
|
+
|
580
|
+
**Location:** [Channel.ts:87-92](Channel.ts#L87-L92), [Channel.ts:137-140](Channel.ts#L137-L140)
|
581
|
+
|
582
|
+
**Problem:**
|
583
|
+
The channel limit system has several issues:
|
584
|
+
|
585
|
+
```typescript
|
586
|
+
public addMember(client: I_WebsocketClient) {
|
587
|
+
if (!this.canAddMember()) return false; // Silent failure
|
588
|
+
this.members.set(client.id, client);
|
589
|
+
client.joinChannel(this);
|
590
|
+
return client; // Returns different types (client or false)
|
591
|
+
}
|
592
|
+
```
|
593
|
+
|
594
|
+
**Issues:**
|
595
|
+
1. **Type inconsistency** - Returns `I_WebsocketClient | false`
|
596
|
+
2. **Silent failure** - No indication WHY the add failed (full? already member? error?)
|
597
|
+
3. **No overflow handling** - What happens when channel is full? No event, no queue, nothing
|
598
|
+
4. **Race condition** - `canAddMember()` checked separately from `members.set()`, could overflow between checks
|
599
|
+
5. **No "waiting list" or "channel full" event** - Clients don't know why they can't join
|
600
|
+
|
601
|
+
**Solution - Return Result Object:**
|
602
|
+
|
603
|
+
```typescript
|
604
|
+
// websocket.types.ts
|
605
|
+
export type AddMemberResult =
|
606
|
+
| { success: true; client: I_WebsocketClient }
|
607
|
+
| { success: false; reason: 'full' | 'already_member' | 'error'; error?: Error };
|
608
|
+
|
609
|
+
// Channel.ts
|
610
|
+
public addMember(client: I_WebsocketClient): AddMemberResult {
|
611
|
+
// Check if already a member
|
612
|
+
if (this.members.has(client.id)) {
|
613
|
+
return { success: false, reason: 'already_member' };
|
614
|
+
}
|
615
|
+
|
616
|
+
// Check capacity (atomic check)
|
617
|
+
if (this.members.size >= this.limit) {
|
618
|
+
// Notify client why they can't join
|
619
|
+
this.notifyChannelFull(client);
|
620
|
+
return { success: false, reason: 'full' };
|
621
|
+
}
|
622
|
+
|
623
|
+
try {
|
624
|
+
this.members.set(client.id, client);
|
625
|
+
client.joinChannel(this);
|
626
|
+
return { success: true, client };
|
627
|
+
} catch (error) {
|
628
|
+
// Rollback
|
629
|
+
this.members.delete(client.id);
|
630
|
+
return {
|
631
|
+
success: false,
|
632
|
+
reason: 'error',
|
633
|
+
error: error instanceof Error ? error : new Error(String(error))
|
634
|
+
};
|
635
|
+
}
|
636
|
+
}
|
637
|
+
|
638
|
+
private notifyChannelFull(client: I_WebsocketClient): void {
|
639
|
+
client.send({
|
640
|
+
type: E_WebsocketMessageType.ERROR,
|
641
|
+
content: {
|
642
|
+
message: `Channel "${this.name}" is full (${this.limit} members)`,
|
643
|
+
code: 'CHANNEL_FULL',
|
644
|
+
channel: this.id
|
645
|
+
}
|
646
|
+
});
|
647
|
+
}
|
648
|
+
|
649
|
+
// Update interface
|
650
|
+
export interface I_WebsocketChannel<T extends Websocket = Websocket> extends I_WebsocketChannelEntity<T> {
|
651
|
+
// ... other methods ...
|
652
|
+
addMember(entity: I_WebsocketClient): AddMemberResult;
|
653
|
+
}
|
654
|
+
|
655
|
+
// Usage example
|
656
|
+
const result = channel.addMember(client);
|
657
|
+
if (!result.success) {
|
658
|
+
Lib.Warn(`Failed to add client: ${result.reason}`);
|
659
|
+
if (result.reason === 'full') {
|
660
|
+
// Maybe add to waiting list or redirect to another channel
|
661
|
+
}
|
662
|
+
}
|
663
|
+
```
|
664
|
+
|
665
|
+
**Alternative - Add Waiting List (Advanced):**
|
666
|
+
|
667
|
+
```typescript
|
668
|
+
export default class Channel<T extends Websocket = Websocket> implements I_WebsocketChannel<T> {
|
669
|
+
public members: Map<string, I_WebsocketClient>;
|
670
|
+
public waitingList: Map<string, I_WebsocketClient>;
|
671
|
+
public limit: number;
|
672
|
+
|
673
|
+
constructor(...) {
|
674
|
+
this.waitingList = new Map();
|
675
|
+
}
|
676
|
+
|
677
|
+
public addMember(client: I_WebsocketClient, skipQueue: boolean = false): AddMemberResult {
|
678
|
+
if (this.members.has(client.id)) {
|
679
|
+
return { success: false, reason: 'already_member' };
|
680
|
+
}
|
681
|
+
|
682
|
+
// If channel is full and not skipping queue, add to waiting list
|
683
|
+
if (this.members.size >= this.limit && !skipQueue) {
|
684
|
+
this.waitingList.set(client.id, client);
|
685
|
+
client.send({
|
686
|
+
type: E_WebsocketMessageType.CHANNEL_QUEUE,
|
687
|
+
content: {
|
688
|
+
message: `Channel is full. Position in queue: ${this.waitingList.size}`,
|
689
|
+
position: this.waitingList.size,
|
690
|
+
channel: this.id
|
691
|
+
}
|
692
|
+
});
|
693
|
+
return { success: false, reason: 'full', queued: true };
|
694
|
+
}
|
695
|
+
|
696
|
+
this.members.set(client.id, client);
|
697
|
+
client.joinChannel(this);
|
698
|
+
return { success: true, client };
|
699
|
+
}
|
700
|
+
|
701
|
+
public removeMember(entity: I_WebsocketEntity): I_WebsocketClient | false {
|
702
|
+
const client = this.members.get(entity.id);
|
703
|
+
if (!client) return false;
|
704
|
+
|
705
|
+
client.leaveChannel(this);
|
706
|
+
this.members.delete(entity.id);
|
707
|
+
|
708
|
+
// Process waiting list
|
709
|
+
this.processWaitingList();
|
710
|
+
|
711
|
+
return client;
|
712
|
+
}
|
713
|
+
|
714
|
+
private processWaitingList(): void {
|
715
|
+
if (this.waitingList.size === 0) return;
|
716
|
+
if (this.members.size >= this.limit) return;
|
717
|
+
|
718
|
+
// Add first client from waiting list
|
719
|
+
const nextClient = this.waitingList.values().next().value;
|
720
|
+
if (nextClient) {
|
721
|
+
this.waitingList.delete(nextClient.id);
|
722
|
+
this.addMember(nextClient, true); // Skip queue check
|
723
|
+
}
|
724
|
+
}
|
725
|
+
}
|
726
|
+
```
|
727
|
+
|
728
|
+
---
|
729
|
+
|
730
|
+
## Improvements & Recommendations
|
731
|
+
|
732
|
+
### Improvement #1: Document Client ↔ Channel Interaction Contract
|
733
|
+
|
734
|
+
**Problem:**
|
735
|
+
The interaction between `Client` and `Channel` is implicit and confusing. Developers extending these classes don't have clear guidance on:
|
736
|
+
- Who is responsible for what?
|
737
|
+
- What are the guarantees?
|
738
|
+
- What order do things happen?
|
739
|
+
- What can fail and how?
|
740
|
+
|
741
|
+
**Solution - Add Comprehensive JSDoc:**
|
742
|
+
|
743
|
+
```typescript
|
744
|
+
/**
|
745
|
+
* Channel represents a pub/sub topic for WebSocket clients.
|
746
|
+
*
|
747
|
+
* ## Membership Contract
|
748
|
+
*
|
749
|
+
* ### Adding Members
|
750
|
+
* The channel-client relationship follows this contract:
|
751
|
+
*
|
752
|
+
* 1. Channel validates capacity (`canAddMember()`)
|
753
|
+
* 2. Channel adds client to `members` map
|
754
|
+
* 3. Channel calls `client.joinChannel(this)`
|
755
|
+
* 4. Client subscribes to channel topic via Bun's pub/sub
|
756
|
+
* 5. Client adds channel to its `channels` map
|
757
|
+
* 6. Client sends join notification (if `send=true`)
|
758
|
+
*
|
759
|
+
* ### Removing Members
|
760
|
+
*
|
761
|
+
* 1. Channel removes client from `members` map
|
762
|
+
* 2. Channel calls `client.leaveChannel(this)`
|
763
|
+
* 3. Client removes channel from its `channels` map
|
764
|
+
* 4. Client unsubscribes from channel topic
|
765
|
+
* 5. Client sends leave notification (if `send=true`)
|
766
|
+
*
|
767
|
+
* ### Guarantees
|
768
|
+
*
|
769
|
+
* - If `addMember()` returns success, the client IS subscribed to the channel
|
770
|
+
* - If `removeMember()` completes, the client IS unsubscribed from the channel
|
771
|
+
* - Channel member count never exceeds `limit`
|
772
|
+
* - A client can be a member of multiple channels
|
773
|
+
*
|
774
|
+
* ### Error Handling
|
775
|
+
*
|
776
|
+
* - If subscription fails, membership is rolled back
|
777
|
+
* - If send fails, membership remains (message delivery is not guaranteed)
|
778
|
+
* - All state changes are atomic
|
779
|
+
*
|
780
|
+
* @example
|
781
|
+
* ```typescript
|
782
|
+
* const channel = new Channel("game-1", "Game Room 1", ws, 10);
|
783
|
+
* const result = channel.addMember(client);
|
784
|
+
* if (result.success) {
|
785
|
+
* channel.broadcast({ type: "player.joined", content: { player: client.whoami() } });
|
786
|
+
* }
|
787
|
+
* ```
|
788
|
+
*/
|
789
|
+
export default class Channel<T extends Websocket = Websocket> implements I_WebsocketChannel<T> {
|
790
|
+
// ...
|
791
|
+
}
|
792
|
+
|
793
|
+
/**
|
794
|
+
* Client represents a connected WebSocket client.
|
795
|
+
*
|
796
|
+
* ## Channel Membership
|
797
|
+
*
|
798
|
+
* Clients maintain their own list of joined channels and handle
|
799
|
+
* subscription to Bun's pub/sub topics.
|
800
|
+
*
|
801
|
+
* ### Join Flow
|
802
|
+
*
|
803
|
+
* When `joinChannel()` is called:
|
804
|
+
* 1. Client validates it's not already a member
|
805
|
+
* 2. Client subscribes to channel's pub/sub topic
|
806
|
+
* 3. Client adds channel to `channels` map
|
807
|
+
* 4. Client sends join notification (optional)
|
808
|
+
*
|
809
|
+
* ⚠️ NOTE: `joinChannel()` is typically called BY the channel,
|
810
|
+
* not directly by user code. Use `channel.addMember(client)` instead.
|
811
|
+
*
|
812
|
+
* @example
|
813
|
+
* ```typescript
|
814
|
+
* // ✅ Correct: Let channel manage membership
|
815
|
+
* channel.addMember(client);
|
816
|
+
*
|
817
|
+
* // ❌ Incorrect: Don't call directly
|
818
|
+
* client.joinChannel(channel);
|
819
|
+
* ```
|
820
|
+
*/
|
821
|
+
export default class Client implements I_WebsocketClient {
|
822
|
+
/**
|
823
|
+
* Join a channel.
|
824
|
+
*
|
825
|
+
* ⚠️ INTERNAL USE: This is called by `Channel.addMember()`.
|
826
|
+
* Do not call directly unless you know what you're doing.
|
827
|
+
*
|
828
|
+
* @param channel - The channel to join
|
829
|
+
* @param send - Whether to send join notification (default: true)
|
830
|
+
* @internal
|
831
|
+
*/
|
832
|
+
public joinChannel(channel: I_WebsocketChannel, send: boolean = true) {
|
833
|
+
// ...
|
834
|
+
}
|
835
|
+
}
|
836
|
+
```
|
837
|
+
|
838
|
+
---
|
839
|
+
|
840
|
+
### Improvement #2: Add JSDoc Comments for Static vs Instance Method Patterns
|
841
|
+
|
842
|
+
**Problem:**
|
843
|
+
The `Websocket` class exposes both static and instance methods, which is confusing:
|
844
|
+
|
845
|
+
```typescript
|
846
|
+
// Static
|
847
|
+
Websocket.Broadcast(channel, message);
|
848
|
+
Websocket.GetClient(id);
|
849
|
+
|
850
|
+
// Instance (via singleton)
|
851
|
+
const ws = Websocket.GetInstance<Websocket>();
|
852
|
+
ws.createChannel(id, name);
|
853
|
+
```
|
854
|
+
|
855
|
+
It's unclear which to use when.
|
856
|
+
|
857
|
+
**Solution - Add Comprehensive JSDoc:**
|
858
|
+
|
859
|
+
```typescript
|
860
|
+
/**
|
861
|
+
* Websocket is a singleton that manages WebSocket clients, channels, and message routing.
|
862
|
+
*
|
863
|
+
* ## API Design: Static vs Instance Methods
|
864
|
+
*
|
865
|
+
* This class uses a **Static Facade Pattern** where:
|
866
|
+
*
|
867
|
+
* ### Static Methods (Public API)
|
868
|
+
* Use these in your application code. They provide a convenient, stateless interface:
|
869
|
+
* ```typescript
|
870
|
+
* Websocket.Broadcast("global", { type: "announcement", content: { message: "Hello" } });
|
871
|
+
* const client = Websocket.GetClient("user-123");
|
872
|
+
* Websocket.CreateChannel("lobby", "Game Lobby", 50);
|
873
|
+
* ```
|
874
|
+
*
|
875
|
+
* Static methods internally call the singleton instance:
|
876
|
+
* ```typescript
|
877
|
+
* public static Broadcast(...) {
|
878
|
+
* const ws = this.GetInstance<Websocket>();
|
879
|
+
* ws._channels.get(channel)?.broadcast(message);
|
880
|
+
* }
|
881
|
+
* ```
|
882
|
+
*
|
883
|
+
* ### Instance Methods (Internal/Extension API)
|
884
|
+
* Use these when extending the class or needing direct instance access:
|
885
|
+
* ```typescript
|
886
|
+
* class MyWebsocket extends Websocket {
|
887
|
+
* protected createClient(entity: I_WebsocketEntity) {
|
888
|
+
* // Custom client creation
|
889
|
+
* return new MyCustomClient(entity);
|
890
|
+
* }
|
891
|
+
* }
|
892
|
+
* ```
|
893
|
+
*
|
894
|
+
* ### When to Use Which?
|
895
|
+
*
|
896
|
+
* ✅ **Use Static Methods When:**
|
897
|
+
* - You're in application/business logic
|
898
|
+
* - You want simple, direct API calls
|
899
|
+
* - You don't need to extend the class
|
900
|
+
*
|
901
|
+
* ✅ **Use Instance Methods When:**
|
902
|
+
* - You're extending `Websocket` class
|
903
|
+
* - You need to override behavior
|
904
|
+
* - You're writing internal framework code
|
905
|
+
*
|
906
|
+
* @example
|
907
|
+
* ```typescript
|
908
|
+
* // Application code - use static methods
|
909
|
+
* Websocket.Broadcast("lobby", { type: "chat", content: { message: "Hi!" } });
|
910
|
+
* const client = Websocket.GetClient("user-1");
|
911
|
+
*
|
912
|
+
* // Extension code - use instance methods
|
913
|
+
* class GameWebsocket extends Websocket {
|
914
|
+
* protected createClient(entity: I_WebsocketEntity) {
|
915
|
+
* return new GameClient(entity);
|
916
|
+
* }
|
917
|
+
* }
|
918
|
+
* ```
|
919
|
+
*/
|
920
|
+
export default class Websocket extends Singleton {
|
921
|
+
/**
|
922
|
+
* Broadcast a message to a specific channel.
|
923
|
+
*
|
924
|
+
* This is a **static facade method** for convenient access.
|
925
|
+
* Internally calls the singleton instance.
|
926
|
+
*
|
927
|
+
* @param channel - The channel ID
|
928
|
+
* @param message - The message to broadcast
|
929
|
+
* @param args - Additional arguments (deprecated)
|
930
|
+
*
|
931
|
+
* @throws {Error} If websocket server is not set
|
932
|
+
*
|
933
|
+
* @example
|
934
|
+
* ```typescript
|
935
|
+
* Websocket.Broadcast("game-1", {
|
936
|
+
* type: "game.update",
|
937
|
+
* content: { score: 100 }
|
938
|
+
* });
|
939
|
+
* ```
|
940
|
+
*/
|
941
|
+
public static Broadcast(channel: string, message: WebsocketStructuredMessage, ...args: any[]) {
|
942
|
+
// ...
|
943
|
+
}
|
944
|
+
|
945
|
+
/**
|
946
|
+
* Create a client instance.
|
947
|
+
*
|
948
|
+
* This is an **instance method** meant for extension/override.
|
949
|
+
* Do not call directly - use static methods instead.
|
950
|
+
*
|
951
|
+
* @param entity - The client entity data
|
952
|
+
* @returns The created client
|
953
|
+
*
|
954
|
+
* @protected
|
955
|
+
* @internal
|
956
|
+
*
|
957
|
+
* @example
|
958
|
+
* ```typescript
|
959
|
+
* // ✅ Override in subclass
|
960
|
+
* class MyWebsocket extends Websocket {
|
961
|
+
* protected createClient(entity: I_WebsocketEntity) {
|
962
|
+
* return new MyCustomClient(entity);
|
963
|
+
* }
|
964
|
+
* }
|
965
|
+
* ```
|
966
|
+
*/
|
967
|
+
protected createClient(entity: I_WebsocketEntity): I_WebsocketClient {
|
968
|
+
// ...
|
969
|
+
}
|
970
|
+
}
|
971
|
+
```
|
972
|
+
|
973
|
+
---
|
974
|
+
|
975
|
+
### Improvement #3: Add Client State Management
|
976
|
+
|
977
|
+
**Problem:**
|
978
|
+
Clients have no explicit state tracking. You can't tell if a client is:
|
979
|
+
- Connecting
|
980
|
+
- Connected
|
981
|
+
- Disconnecting
|
982
|
+
- Disconnected
|
983
|
+
|
984
|
+
This makes it hard to:
|
985
|
+
- Prevent sending to disconnected clients
|
986
|
+
- Implement graceful shutdown
|
987
|
+
- Handle reconnection logic
|
988
|
+
- Debug connection issues
|
989
|
+
|
990
|
+
**Solution - Add Explicit State Management:**
|
991
|
+
|
992
|
+
```typescript
|
993
|
+
// websocket.enums.ts
|
994
|
+
export enum E_ClientState {
|
995
|
+
CONNECTING = "connecting",
|
996
|
+
CONNECTED = "connected",
|
997
|
+
DISCONNECTING = "disconnecting",
|
998
|
+
DISCONNECTED = "disconnected",
|
999
|
+
}
|
1000
|
+
|
1001
|
+
// Client.ts
|
1002
|
+
export default class Client implements I_WebsocketClient {
|
1003
|
+
private _id: string;
|
1004
|
+
private _name: string;
|
1005
|
+
private _ws: ServerWebSocket<WebsocketEntityData>;
|
1006
|
+
private _channels: WebsocketChannel<I_WebsocketChannel>;
|
1007
|
+
private _state: E_ClientState;
|
1008
|
+
private _connectedAt?: Date;
|
1009
|
+
private _disconnectedAt?: Date;
|
1010
|
+
|
1011
|
+
constructor(entity: I_WebsocketEntity) {
|
1012
|
+
this._id = entity.id;
|
1013
|
+
this._name = entity.name;
|
1014
|
+
this._ws = entity.ws;
|
1015
|
+
this._channels = new Map();
|
1016
|
+
this._state = E_ClientState.CONNECTING;
|
1017
|
+
}
|
1018
|
+
|
1019
|
+
public get state(): E_ClientState {
|
1020
|
+
return this._state;
|
1021
|
+
}
|
1022
|
+
|
1023
|
+
public canReceiveMessages(): boolean {
|
1024
|
+
return this._state === E_ClientState.CONNECTED;
|
1025
|
+
}
|
1026
|
+
|
1027
|
+
public markConnected(): void {
|
1028
|
+
this._state = E_ClientState.CONNECTED;
|
1029
|
+
this._connectedAt = new Date();
|
1030
|
+
}
|
1031
|
+
|
1032
|
+
public markDisconnecting(): void {
|
1033
|
+
this._state = E_ClientState.DISCONNECTING;
|
1034
|
+
}
|
1035
|
+
|
1036
|
+
public markDisconnected(): void {
|
1037
|
+
this._state = E_ClientState.DISCONNECTED;
|
1038
|
+
this._disconnectedAt = new Date();
|
1039
|
+
}
|
1040
|
+
|
1041
|
+
public send(message: WebsocketStructuredMessage | string, options?: WebsocketMessageOptions): void {
|
1042
|
+
// Check state before sending
|
1043
|
+
if (!this.canReceiveMessages()) {
|
1044
|
+
Lib.Warn(`Cannot send to client ${this.id} in state ${this._state}`);
|
1045
|
+
return;
|
1046
|
+
}
|
1047
|
+
|
1048
|
+
try {
|
1049
|
+
if (Guards.IsString(message)) {
|
1050
|
+
const msg: WebsocketMessage = {
|
1051
|
+
type: "message",
|
1052
|
+
content: { message },
|
1053
|
+
};
|
1054
|
+
message = Message.Create(msg, options);
|
1055
|
+
}
|
1056
|
+
this.ws.send(JSON.stringify({ client: this.whoami(), ...message }));
|
1057
|
+
} catch (error) {
|
1058
|
+
Lib.Error(`Failed to send message to client ${this.id}:`, error);
|
1059
|
+
if (error instanceof Error && error.message.includes('closed')) {
|
1060
|
+
this.markDisconnected();
|
1061
|
+
}
|
1062
|
+
}
|
1063
|
+
}
|
1064
|
+
|
1065
|
+
public getConnectionInfo() {
|
1066
|
+
return {
|
1067
|
+
id: this.id,
|
1068
|
+
name: this.name,
|
1069
|
+
state: this._state,
|
1070
|
+
connectedAt: this._connectedAt,
|
1071
|
+
disconnectedAt: this._disconnectedAt,
|
1072
|
+
uptime: this._connectedAt ? Date.now() - this._connectedAt.getTime() : 0,
|
1073
|
+
channelCount: this._channels.size,
|
1074
|
+
};
|
1075
|
+
}
|
1076
|
+
}
|
1077
|
+
|
1078
|
+
// Websocket.ts - Update handlers
|
1079
|
+
private clientConnected = (ws: ServerWebSocket<WebsocketEntityData>) => {
|
1080
|
+
const client = Websocket.CreateClient({ id: ws.data.id, ws: ws, name: ws.data.name });
|
1081
|
+
this._clients.set(client.id, client);
|
1082
|
+
|
1083
|
+
client.send({
|
1084
|
+
type: E_WebsocketMessageType.CLIENT_CONNECTED,
|
1085
|
+
content: { message: "Welcome to the server", client: client.whoami() }
|
1086
|
+
});
|
1087
|
+
|
1088
|
+
const global = this._channels.get("global");
|
1089
|
+
if (global) global.addMember(client);
|
1090
|
+
|
1091
|
+
// Mark as fully connected
|
1092
|
+
client.markConnected();
|
1093
|
+
|
1094
|
+
if (this._ws_interface_handlers.open) this._ws_interface_handlers.open(ws);
|
1095
|
+
};
|
1096
|
+
|
1097
|
+
private clientDisconnected = (ws: ServerWebSocket<WebsocketEntityData>, code: number, reason: string) => {
|
1098
|
+
const client = this._clients.get(ws.data.id);
|
1099
|
+
if (!client) return;
|
1100
|
+
|
1101
|
+
client.markDisconnecting();
|
1102
|
+
|
1103
|
+
if (this._ws_interface_handlers.close) this._ws_interface_handlers.close(ws, code, reason);
|
1104
|
+
|
1105
|
+
this._channels.forEach((channel) => {
|
1106
|
+
channel.removeMember(client);
|
1107
|
+
});
|
1108
|
+
|
1109
|
+
client.markDisconnected();
|
1110
|
+
this._clients.delete(ws.data.id);
|
1111
|
+
};
|
1112
|
+
|
1113
|
+
// Add utility methods
|
1114
|
+
public static GetConnectedClients(): I_WebsocketClient[] {
|
1115
|
+
const ws = this.GetInstance<Websocket>();
|
1116
|
+
return Array.from(ws._clients.values()).filter(
|
1117
|
+
client => client.state === E_ClientState.CONNECTED
|
1118
|
+
);
|
1119
|
+
}
|
1120
|
+
|
1121
|
+
public static GetClientStats() {
|
1122
|
+
const ws = this.GetInstance<Websocket>();
|
1123
|
+
const stats = {
|
1124
|
+
total: ws._clients.size,
|
1125
|
+
connecting: 0,
|
1126
|
+
connected: 0,
|
1127
|
+
disconnecting: 0,
|
1128
|
+
disconnected: 0,
|
1129
|
+
};
|
1130
|
+
|
1131
|
+
for (const client of ws._clients.values()) {
|
1132
|
+
switch (client.state) {
|
1133
|
+
case E_ClientState.CONNECTING: stats.connecting++; break;
|
1134
|
+
case E_ClientState.CONNECTED: stats.connected++; break;
|
1135
|
+
case E_ClientState.DISCONNECTING: stats.disconnecting++; break;
|
1136
|
+
case E_ClientState.DISCONNECTED: stats.disconnected++; break;
|
1137
|
+
}
|
1138
|
+
}
|
1139
|
+
|
1140
|
+
return stats;
|
1141
|
+
}
|
1142
|
+
```
|
1143
|
+
|
1144
|
+
**Benefits:**
|
1145
|
+
- Prevents sending to disconnected clients
|
1146
|
+
- Enables connection metrics/monitoring
|
1147
|
+
- Makes debugging easier
|
1148
|
+
- Supports graceful shutdown
|
1149
|
+
- Foundation for reconnection logic
|
1150
|
+
|
1151
|
+
---
|
1152
|
+
|
1153
|
+
## Summary
|
1154
|
+
|
1155
|
+
### Critical Fixes Needed (Priority Order)
|
1156
|
+
1. **Issue #10** - Separate transport options from wire format (security + API clarity)
|
1157
|
+
2. **Issue #5** - Fix broadcast bug (excludeClients ignored in pub/sub path)
|
1158
|
+
3. **Issue #3** - Refactor circular dependency (architecture issue)
|
1159
|
+
4. **Issue #8** - Remove wasteful Message instances (performance)
|
1160
|
+
5. **Issue #4** - Improve channel limit handling (better UX)
|
1161
|
+
|
1162
|
+
### Recommended Improvements
|
1163
|
+
1. Document Client ↔ Channel interaction contract
|
1164
|
+
2. Add JSDoc for static vs instance method patterns
|
1165
|
+
3. Add client state management (CONNECTING, CONNECTED, etc.)
|
1166
|
+
|
1167
|
+
### Quick Wins (Easy to Implement)
|
1168
|
+
- Issue #8: Change Message to static-only (1 file change)
|
1169
|
+
- Issue #5: Remove broadcast optimization (simplify logic)
|
1170
|
+
- Improvement #2: Add JSDoc comments (documentation only)
|
1171
|
+
|
1172
|
+
### Requires More Thought
|
1173
|
+
- Issue #3: Circular dependency refactor (affects multiple files)
|
1174
|
+
- Issue #10: Type separation (might break existing code)
|
1175
|
+
- Improvement #3: Client state (new feature, needs testing)
|