topsyde-utils 1.0.205 → 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.
Files changed (35) hide show
  1. package/dist/index.d.ts +3 -3
  2. package/dist/index.js +2 -2
  3. package/dist/index.js.map +1 -1
  4. package/dist/server/bun/websocket/Channel.d.ts +25 -3
  5. package/dist/server/bun/websocket/Channel.js +80 -26
  6. package/dist/server/bun/websocket/Channel.js.map +1 -1
  7. package/dist/server/bun/websocket/Client.d.ts +34 -1
  8. package/dist/server/bun/websocket/Client.js +95 -18
  9. package/dist/server/bun/websocket/Client.js.map +1 -1
  10. package/dist/server/bun/websocket/Message.d.ts +6 -10
  11. package/dist/server/bun/websocket/Message.js +31 -32
  12. package/dist/server/bun/websocket/Message.js.map +1 -1
  13. package/dist/server/bun/websocket/Websocket.d.ts +35 -4
  14. package/dist/server/bun/websocket/Websocket.js +71 -12
  15. package/dist/server/bun/websocket/Websocket.js.map +1 -1
  16. package/dist/server/bun/websocket/index.d.ts +1 -1
  17. package/dist/server/bun/websocket/index.js +1 -1
  18. package/dist/server/bun/websocket/index.js.map +1 -1
  19. package/dist/server/bun/websocket/websocket.enums.d.ts +6 -0
  20. package/dist/server/bun/websocket/websocket.enums.js +7 -0
  21. package/dist/server/bun/websocket/websocket.enums.js.map +1 -1
  22. package/dist/server/bun/websocket/websocket.types.d.ts +60 -3
  23. package/dist/server/bun/websocket/websocket.types.js.map +1 -1
  24. package/package.json +1 -1
  25. package/src/__tests__/app.test.ts +1 -1
  26. package/src/__tests__/singleton.test.ts +6 -4
  27. package/src/index.ts +4 -2
  28. package/src/server/bun/websocket/Channel.ts +89 -36
  29. package/src/server/bun/websocket/Client.ts +109 -19
  30. package/src/server/bun/websocket/ISSUES.md +1175 -0
  31. package/src/server/bun/websocket/Message.ts +36 -49
  32. package/src/server/bun/websocket/Websocket.ts +72 -12
  33. package/src/server/bun/websocket/index.ts +1 -1
  34. package/src/server/bun/websocket/websocket.enums.ts +7 -0
  35. package/src/server/bun/websocket/websocket.types.ts +58 -3
@@ -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)