topsyde-utils 1.0.205 → 1.0.207

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 (31) hide show
  1. package/dist/index.d.ts +2 -2
  2. package/dist/index.js +1 -1
  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 +34 -4
  14. package/dist/server/bun/websocket/Websocket.js +70 -12
  15. package/dist/server/bun/websocket/Websocket.js.map +1 -1
  16. package/dist/server/bun/websocket/websocket.enums.d.ts +6 -0
  17. package/dist/server/bun/websocket/websocket.enums.js +7 -0
  18. package/dist/server/bun/websocket/websocket.enums.js.map +1 -1
  19. package/dist/server/bun/websocket/websocket.types.d.ts +60 -3
  20. package/dist/server/bun/websocket/websocket.types.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/__tests__/app.test.ts +1 -1
  23. package/src/__tests__/singleton.test.ts +6 -4
  24. package/src/index.ts +3 -1
  25. package/src/server/bun/websocket/Channel.ts +89 -36
  26. package/src/server/bun/websocket/Client.ts +109 -19
  27. package/src/server/bun/websocket/ISSUES.md +1175 -0
  28. package/src/server/bun/websocket/Message.ts +36 -49
  29. package/src/server/bun/websocket/Websocket.ts +71 -12
  30. package/src/server/bun/websocket/websocket.enums.ts +7 -0
  31. package/src/server/bun/websocket/websocket.types.ts +58 -3
@@ -292,8 +292,10 @@ describe("Singleton", () => {
292
292
  });
293
293
  it("should allow custom client implementation", () => {
294
294
  class CustomClient extends Client {
295
- public send(message: WebsocketStructuredMessage) {
296
- console.log("CONSOLE LOG");
295
+ public send(message: string, options?: app.WebsocketMessageOptions): void;
296
+ public send(message: WebsocketStructuredMessage): void;
297
+ public send(message: WebsocketStructuredMessage | string, options?: app.WebsocketMessageOptions): void {
298
+ console.log("CUSTOM SEND");
297
299
  }
298
300
  }
299
301
  const ws = app.Websocket.GetInstance<app.Websocket>({ clientClass: CustomClient });
@@ -382,7 +384,7 @@ describe("Singleton", () => {
382
384
  // Update expectations to match actual structure - we don't care about exact format
383
385
  // as long as it contains the message
384
386
  expect(parsedJson).toHaveProperty("type", message.type);
385
- expect(parsedJson).toHaveProperty("channel", channel.name);
387
+ expect(parsedJson).toHaveProperty("channel", channel.id);
386
388
 
387
389
  spy.mockRestore();
388
390
  });
@@ -395,7 +397,7 @@ describe("Singleton", () => {
395
397
  ws.set(server);
396
398
 
397
399
  const channel = ws.createChannel("test", "Test Channel");
398
- const message = { type: "test", content: { message: "test message" }, };
400
+ const message = { type: "test", content: { message: "test message" } };
399
401
  channel.broadcast(message, { debug: true, client: { id: "test", name: "Test Client" } });
400
402
  expect(mockPublish).toHaveBeenCalledWith(channel.id, expect.any(String));
401
403
  });
package/src/index.ts CHANGED
@@ -65,7 +65,7 @@ export { RxjsDataType, NamespaceActions, MultiNamespaceActions } from "./client/
65
65
  export { ControllerResponse, ControllerAction, ControllerMap, ControllerOptions } from "./server/controller";
66
66
  export { Routes } from "./server/bun/router/routes";
67
67
  export { WebsocketConstructorOptions, I_WebsocketConstructor } from "./server/bun/websocket/Websocket";
68
- export { E_WebsocketMessageType, E_WebsocketMessagePriority } from "./server/bun/websocket/websocket.enums";
68
+ export { E_WebsocketMessageType, E_WebsocketMessagePriority, E_ClientState } from "./server/bun/websocket/websocket.enums";
69
69
  export {
70
70
  BunWebsocketMessage,
71
71
  WebsocketChannel,
@@ -80,6 +80,8 @@ export {
80
80
  I_WebsocketClient,
81
81
  I_WebsocketChannelEntity,
82
82
  BroadcastOptions,
83
+ AddMemberResult,
84
+ AddMemberOptions,
83
85
  I_WebsocketChannel,
84
86
  WebsocketInterfaceHandlers,
85
87
  I_WebsocketInterface,
@@ -1,15 +1,25 @@
1
1
  import { Guards, Lib } from "../../../utils";
2
2
  import Message from "./Message";
3
3
  import Websocket from "./Websocket";
4
- import type {
5
- BroadcastOptions,
6
- I_WebsocketChannel,
7
- I_WebsocketClient,
8
- I_WebsocketEntity,
9
- WebsocketChannel,
10
- WebsocketMessage
11
- } from "./websocket.types";
12
-
4
+ import type { BroadcastOptions, I_WebsocketChannel, I_WebsocketClient, I_WebsocketEntity, WebsocketChannel, WebsocketMessage, AddMemberResult, AddMemberOptions } from "./websocket.types";
5
+ import { E_WebsocketMessageType } from "./websocket.enums";
6
+
7
+ /**
8
+ * Channel - Pub/sub topic for WebSocket clients
9
+ *
10
+ * ## Membership Contract
11
+ * - `addMember()` validates capacity and adds to `members` map
12
+ * - Client drives join via `joinChannel()` which subscribes and handles rollback
13
+ * - If subscription fails, membership is automatically rolled back
14
+ * - Member count never exceeds `limit`
15
+ *
16
+ * @example
17
+ * const channel = new Channel("game-1", "Game Room", ws, 10);
18
+ * const result = channel.addMember(client);
19
+ * if (result.success) {
20
+ * channel.broadcast({ type: "player.joined", content: { player: client.whoami() } });
21
+ * }
22
+ */
13
23
  export default class Channel<T extends Websocket = Websocket> implements I_WebsocketChannel<T> {
14
24
  public createdAt: Date = new Date();
15
25
  public id: string;
@@ -18,7 +28,6 @@ export default class Channel<T extends Websocket = Websocket> implements I_Webso
18
28
  public members: Map<string, I_WebsocketClient>;
19
29
  public metadata: Record<string, string>;
20
30
  public ws: T;
21
- private message: Message;
22
31
 
23
32
  constructor(id: string, name: string, ws: T, limit?: number, members?: Map<string, I_WebsocketClient>, metadata?: Record<string, string>) {
24
33
  this.id = id;
@@ -27,8 +36,6 @@ export default class Channel<T extends Websocket = Websocket> implements I_Webso
27
36
  this.members = members ?? new Map();
28
37
  this.metadata = metadata ?? {};
29
38
  this.ws = ws;
30
- this.message = new Message();
31
-
32
39
  }
33
40
 
34
41
  public broadcast(message: WebsocketMessage | string, options?: BroadcastOptions) {
@@ -39,30 +46,34 @@ export default class Channel<T extends Websocket = Websocket> implements I_Webso
39
46
  };
40
47
  message = msg;
41
48
  }
42
- const output = this.message.create(message, { ...options, channel: this.name });
43
- if (options) {
44
- // Include channel metadata if requested
45
- if (options.includeMetadata) {
46
- output.metadata = options.includeMetadata === true ? this.getMetadata() : this.getFilteredMetadata(options.includeMetadata);
47
- }
48
49
 
49
- // Handle excluded clients if needed
50
- if (options.excludeClients && options.excludeClients.length > 0) {
51
- // For large channels with many excluded clients, it might be more efficient
52
- // to send directly to each client instead of using channel publish
53
- if (this.members.size > 10 && options.excludeClients.length > this.members.size / 3) {
54
- const serializedMessage = this.message.serialize(output);
55
- for (const [clientId, client] of this.members) {
56
- if (!options.excludeClients.includes(clientId)) {
57
- client.ws.send(serializedMessage);
58
- }
50
+ const output = Message.Create(message, { ...options, channel: this.id });
51
+
52
+ // Include channel metadata if requested
53
+ if (options?.includeMetadata) {
54
+ output.metadata = options.includeMetadata === true ? this.getMetadata() : this.getFilteredMetadata(options.includeMetadata);
55
+ }
56
+
57
+ const serializedMessage = Message.Serialize(output);
58
+
59
+ // If we need to exclude clients, send individually to prevent excluded clients from receiving
60
+ if (options?.excludeClients && options.excludeClients.length > 0) {
61
+ const excludeSet = new Set(options.excludeClients); // O(1) lookup
62
+
63
+ for (const [clientId, client] of this.members) {
64
+ if (!excludeSet.has(clientId)) {
65
+ try {
66
+ client.ws.send(serializedMessage);
67
+ } catch (error) {
68
+ Lib.Warn(`Failed to send to client ${clientId}:`, error);
59
69
  }
60
- return;
61
70
  }
62
71
  }
72
+ return;
63
73
  }
64
- // Publish to the channel
65
- this.ws.server.publish(this.id, this.message.serialize(output));
74
+
75
+ // Otherwise use pub/sub for everyone
76
+ this.ws.server.publish(this.id, serializedMessage);
66
77
  }
67
78
 
68
79
  // Helper method for filtered metadata
@@ -84,11 +95,53 @@ export default class Channel<T extends Websocket = Websocket> implements I_Webso
84
95
  return this.members.has(client.id);
85
96
  }
86
97
 
87
- public addMember(client: I_WebsocketClient) {
88
- if (!this.canAddMember()) return false;
89
- this.members.set(client.id, client);
90
- client.joinChannel(this);
91
- return client;
98
+ public addMember(client: I_WebsocketClient, options?: AddMemberOptions): AddMemberResult {
99
+ // Check if already a member
100
+ if (this.members.has(client.id)) {
101
+ return { success: false, reason: 'already_member' };
102
+ }
103
+
104
+ // Check capacity (atomic check)
105
+ if (this.members.size >= this.limit) {
106
+ // Optionally notify client why they can't join
107
+ if (options?.notify_when_full) {
108
+ this.notifyChannelFull(client);
109
+ }
110
+ return { success: false, reason: 'full' };
111
+ }
112
+
113
+ try {
114
+ this.members.set(client.id, client);
115
+ return { success: true, client };
116
+ } catch (error) {
117
+ // Rollback
118
+ this.members.delete(client.id);
119
+ return {
120
+ success: false,
121
+ reason: 'error',
122
+ error: error instanceof Error ? error : new Error(String(error))
123
+ };
124
+ }
125
+ }
126
+
127
+ private notifyChannelFull(client: I_WebsocketClient): void {
128
+ client.send({
129
+ type: E_WebsocketMessageType.ERROR,
130
+ content: {
131
+ message: `Channel "${this.name}" is full (${this.limit} members)`,
132
+ code: 'CHANNEL_FULL',
133
+ channel: this.id
134
+ }
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Internal method to remove a member without triggering client-side cleanup.
140
+ * Used for rollback operations when joinChannel fails.
141
+ * @internal
142
+ */
143
+ public removeMemberInternal(client: I_WebsocketClient): void {
144
+ this.members.delete(client.id);
92
145
  }
93
146
 
94
147
  public removeMember(entity: I_WebsocketEntity) {
@@ -9,15 +9,33 @@ import type {
9
9
  WebsocketMessageOptions,
10
10
  WebsocketMessage,
11
11
  } from "./websocket.types";
12
- import { E_WebsocketMessageType } from "./websocket.enums";
12
+ import { E_WebsocketMessageType, E_ClientState } from "./websocket.enums";
13
13
  import { Guards, Lib } from "../../../utils";
14
14
  import Message from "./Message";
15
15
 
16
+ /**
17
+ * Client - Connected WebSocket client with channel membership
18
+ *
19
+ * ## Channel Membership
20
+ * - Maintains own channel list and handles Bun pub/sub subscriptions
21
+ * - `joinChannel()` adds to channel, subscribes, and handles rollback on failure
22
+ * - Always use `channel.addMember(client)` in application code, not `client.joinChannel()` directly
23
+ *
24
+ * @example
25
+ * // ✅ Correct
26
+ * channel.addMember(client);
27
+ *
28
+ * // ❌ Incorrect - internal use only
29
+ * client.joinChannel(channel);
30
+ */
16
31
  export default class Client implements I_WebsocketClient {
17
32
  private _id: string;
18
33
  private _name: string;
19
34
  private _ws: ServerWebSocket<WebsocketEntityData>;
20
35
  private _channels: WebsocketChannel<I_WebsocketChannel>;
36
+ private _state: E_ClientState;
37
+ private _connectedAt?: Date;
38
+ private _disconnectedAt?: Date;
21
39
 
22
40
  private set ws(value: ServerWebSocket<WebsocketEntityData>) {
23
41
  this._ws = value;
@@ -51,25 +69,84 @@ export default class Client implements I_WebsocketClient {
51
69
  return this._channels;
52
70
  }
53
71
 
72
+ public get state(): E_ClientState {
73
+ return this._state;
74
+ }
75
+
54
76
  constructor(entity: I_WebsocketEntity) {
55
77
  this._id = entity.id;
56
78
  this._name = entity.name;
57
79
  this._ws = entity.ws;
58
- this.ws = entity.ws;
59
80
  this._channels = new Map();
81
+ this._state = E_ClientState.CONNECTING;
82
+ }
83
+
84
+ public canReceiveMessages(): boolean {
85
+ return this._state === E_ClientState.CONNECTED;
86
+ }
87
+
88
+ public markConnected(): void {
89
+ this._state = E_ClientState.CONNECTED;
90
+ this._connectedAt = new Date();
91
+ }
92
+
93
+ public markDisconnecting(): void {
94
+ this._state = E_ClientState.DISCONNECTING;
60
95
  }
61
96
 
62
- public joinChannel(channel: I_WebsocketChannel, send: boolean = true) {
97
+ public markDisconnected(): void {
98
+ this._state = E_ClientState.DISCONNECTED;
99
+ this._disconnectedAt = new Date();
100
+ }
101
+
102
+ public getConnectionInfo() {
103
+ return {
104
+ id: this.id,
105
+ name: this.name,
106
+ state: this._state,
107
+ connectedAt: this._connectedAt,
108
+ disconnectedAt: this._disconnectedAt,
109
+ uptime: this._connectedAt ? Date.now() - this._connectedAt.getTime() : 0,
110
+ channelCount: this._channels.size,
111
+ };
112
+ }
113
+
114
+ public joinChannel(channel: I_WebsocketChannel, send: boolean = true): boolean {
63
115
  const channel_id = channel.getId();
64
- this.subscribe(channel_id);
65
- this.channels.set(channel_id, channel);
66
- if (send)
67
- this.send({
68
- type: E_WebsocketMessageType.CLIENT_JOIN_CHANNEL,
69
- content: { message: "Welcome to the channel" },
70
- channel: channel_id,
71
- client: this.whoami(),
72
- });
116
+
117
+ // Check if already joined
118
+ if (this.channels.has(channel_id)) {
119
+ return false;
120
+ }
121
+
122
+ // Try to add to channel first
123
+ const result = channel.addMember(this);
124
+ if (!result.success) {
125
+ return false; // Channel full, already member, or other issue
126
+ }
127
+
128
+ try {
129
+ // Subscribe to channel's pub/sub topic
130
+ this.subscribe(channel_id);
131
+ this.channels.set(channel_id, channel);
132
+
133
+ // Send join notification
134
+ if (send) {
135
+ this.send({
136
+ type: E_WebsocketMessageType.CLIENT_JOIN_CHANNEL,
137
+ content: { message: "Welcome to the channel" },
138
+ channel: channel_id,
139
+ client: this.whoami(),
140
+ });
141
+ }
142
+
143
+ return true;
144
+ } catch (error) {
145
+ // Rollback channel membership on failure
146
+ channel.removeMemberInternal(this);
147
+ this.channels.delete(channel_id);
148
+ throw error;
149
+ }
73
150
  }
74
151
 
75
152
  public leaveChannel(channel: I_WebsocketChannel, send: boolean = true) {
@@ -107,14 +184,27 @@ export default class Client implements I_WebsocketClient {
107
184
  public send(message: string, options?: WebsocketMessageOptions): void;
108
185
  public send(message: WebsocketStructuredMessage): void;
109
186
  public send(message: WebsocketStructuredMessage | string, options?: WebsocketMessageOptions): void {
110
- if (Guards.IsString(message)) {
111
- const msg: WebsocketMessage = {
112
- type: "message",
113
- content: { message },
114
- };
115
- message = Message.Create(msg, options);
187
+ // Check state before sending
188
+ if (!this.canReceiveMessages()) {
189
+ Lib.Warn(`Cannot send to client ${this.id} in state ${this._state}`);
190
+ return;
191
+ }
192
+
193
+ try {
194
+ if (Guards.IsString(message)) {
195
+ const msg: WebsocketMessage = {
196
+ type: "message",
197
+ content: { message },
198
+ };
199
+ message = Message.Create(msg, options);
200
+ }
201
+ this.ws.send(JSON.stringify({ client: this.whoami(), ...message }));
202
+ } catch (error) {
203
+ Lib.Warn(`Failed to send message to client ${this.id}:`, error);
204
+ if (error instanceof Error && error.message.includes("closed")) {
205
+ this.markDisconnected();
206
+ }
116
207
  }
117
- this.ws.send(JSON.stringify({ client: this.whoami(), ...message }));
118
208
  }
119
209
 
120
210
  public subscribe(channel: string): void {