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.
Files changed (39) 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/dist/utils/BaseEntity.d.ts +4 -0
  25. package/dist/utils/BaseEntity.js +4 -0
  26. package/dist/utils/BaseEntity.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/__tests__/app.test.ts +1 -1
  29. package/src/__tests__/singleton.test.ts +6 -4
  30. package/src/index.ts +4 -2
  31. package/src/server/bun/websocket/Channel.ts +89 -36
  32. package/src/server/bun/websocket/Client.ts +109 -19
  33. package/src/server/bun/websocket/ISSUES.md +1175 -0
  34. package/src/server/bun/websocket/Message.ts +36 -49
  35. package/src/server/bun/websocket/Websocket.ts +72 -12
  36. package/src/server/bun/websocket/index.ts +1 -1
  37. package/src/server/bun/websocket/websocket.enums.ts +7 -0
  38. package/src/server/bun/websocket/websocket.types.ts +58 -3
  39. package/src/utils/BaseEntity.ts +8 -1
@@ -1,24 +1,23 @@
1
1
  import Guards from "../../../utils/Guards";
2
- import {
3
- WebsocketStructuredMessage,
4
- WebsocketMessage,
5
- WebsocketMessageOptions,
6
- I_WebsocketChannel,
7
- I_WebsocketClient,
8
- WebsocketEntityData,
9
- } from "./websocket.types";
2
+ import { WebsocketStructuredMessage, WebsocketMessage, WebsocketMessageOptions, I_WebsocketClient, WebsocketEntityData } from "./websocket.types";
10
3
 
11
4
  export default class Message {
12
- private messageTemplate: WebsocketStructuredMessage<any>;
13
-
14
- constructor() {
15
- this.messageTemplate = { type: "", content: {}, channel: "", timestamp: "" };
16
- }
17
-
18
- public create(message: WebsocketMessage, options?: WebsocketMessageOptions): WebsocketStructuredMessage {
19
- // Clone the template (faster than creating new objects)
20
- const output = Object.assign({}, this.messageTemplate);
21
- // Set the dynamic properties in a single pass
5
+ // Shared template for all messages
6
+ private static readonly MESSAGE_TEMPLATE: WebsocketStructuredMessage<any> = {
7
+ type: "",
8
+ content: {},
9
+ channel: "",
10
+ timestamp: "",
11
+ };
12
+
13
+ // Private constructor to prevent instantiation
14
+ private constructor() {}
15
+
16
+ public static Create(message: WebsocketMessage, options?: WebsocketMessageOptions): WebsocketStructuredMessage {
17
+ // Clone the template
18
+ const output = Object.assign({}, Message.MESSAGE_TEMPLATE);
19
+
20
+ // Set the dynamic properties
22
21
  output.type = message.type;
23
22
  output.channel = message.channel || options?.channel || "N/A";
24
23
 
@@ -31,7 +30,7 @@ export default class Message {
31
30
  output.content = {};
32
31
  }
33
32
 
34
- // Process options in a single pass if provided
33
+ // Process options if provided
35
34
  if (options) {
36
35
  // Add data if provided
37
36
  if (options.data !== undefined) {
@@ -43,18 +42,20 @@ export default class Message {
43
42
  output.content.data = options.data;
44
43
  }
45
44
  }
45
+
46
46
  // Add client information if provided
47
47
  if (options.client && Guards.IsObject(options.client) && Guards.IsString(options.client.id, true)) {
48
48
  output.client = {
49
49
  id: options.client.id,
50
- name: options.client.name,
50
+ name: options.client.name || "Unknown",
51
51
  };
52
- } /* else {
53
- delete output.client;
54
- } */
52
+ }
55
53
 
56
54
  // Include channel metadata if requested
57
- if (options.includeMetadata !== false) output.metadata = options.metadata;
55
+ // Include channel metadata if provided as an object
56
+ if (options.metadata && Guards.IsObject(options.metadata) && !Guards.IsArray(options.metadata)) {
57
+ output.metadata = options.metadata;
58
+ }
58
59
 
59
60
  // Add timestamp if requested (default: true)
60
61
  if (options.includeTimestamp !== false) {
@@ -81,8 +82,7 @@ export default class Message {
81
82
 
82
83
  // Apply custom transformation if provided
83
84
  if (options.transform) {
84
- const transformed = options.transform(output);
85
- return transformed;
85
+ return options.transform(output);
86
86
  }
87
87
  } else {
88
88
  output.timestamp = new Date().toISOString();
@@ -91,19 +91,21 @@ export default class Message {
91
91
  return output;
92
92
  }
93
93
 
94
- public createWhisper(message: Omit<WebsocketMessage, "type">, options?: WebsocketMessageOptions) {
95
- return this.create({ ...message, content: message.content, channel: message.channel, type: "whisper" }, options);
94
+ public static CreateWhisper(message: Omit<WebsocketMessage, "type">, options?: WebsocketMessageOptions): WebsocketStructuredMessage {
95
+ return Message.Create({ ...message, content: message.content, channel: message.channel, type: "whisper" }, options);
96
+ }
97
+
98
+ public static Serialize<T = string>(message: WebsocketStructuredMessage, transform?: (message: WebsocketStructuredMessage) => T): string | T {
99
+ return transform ? transform(message) : JSON.stringify(message);
96
100
  }
97
101
 
98
- public send(target: I_WebsocketClient, message: WebsocketStructuredMessage): void;
99
- public send(target: I_WebsocketClient, message: WebsocketMessage, options?: WebsocketMessageOptions): void;
100
- public send(target: I_WebsocketClient, message: WebsocketStructuredMessage | WebsocketMessage, options?: WebsocketMessageOptions): void {
101
- target.send(this.create(message, options));
102
+ public static Send(target: I_WebsocketClient, message: WebsocketMessage, options?: WebsocketMessageOptions): void {
103
+ target.send(Message.Create(message, options));
102
104
  }
103
105
 
104
- public alert(target: I_WebsocketClient, reason: string, client?: WebsocketEntityData) {
106
+ public static Alert(target: I_WebsocketClient, reason: string, client?: WebsocketEntityData): void {
105
107
  target.send(
106
- this.create(
108
+ Message.Create(
107
109
  {
108
110
  content: {
109
111
  message: reason,
@@ -115,19 +117,4 @@ export default class Message {
115
117
  ),
116
118
  );
117
119
  }
118
-
119
- public serialize<T = string>(message: WebsocketStructuredMessage, transform?: (message: WebsocketStructuredMessage) => T) {
120
- return transform ? transform(message) : JSON.stringify(message);
121
- }
122
-
123
- public static Serialize<T = string>(message: WebsocketStructuredMessage, transform: (message: WebsocketStructuredMessage) => T): T;
124
- public static Serialize<T = string>(message: WebsocketStructuredMessage, transform?: (message: WebsocketStructuredMessage) => T): string | T;
125
- public static Serialize<T = string>(message: WebsocketStructuredMessage, transform?: (message: WebsocketStructuredMessage) => T): string | T {
126
- return transform ? transform(message) : JSON.stringify(message);
127
- }
128
-
129
- public static Create(message: WebsocketMessage, options?: WebsocketMessageOptions): WebsocketStructuredMessage{
130
- const msg = new Message();
131
- return msg.create(message, options);
132
- }
133
120
  }
@@ -28,6 +28,26 @@ export interface I_WebsocketConstructor {
28
28
  options?: WebsocketConstructorOptions;
29
29
  }
30
30
 
31
+ /**
32
+ * Websocket - Singleton managing clients, channels, and message routing
33
+ *
34
+ * ## API Design: Static vs Instance
35
+ * - **Static methods**: Use in application code (e.g., `Websocket.Broadcast()`, `Websocket.GetClient()`)
36
+ * - **Instance methods**: Use when extending the class (e.g., `protected createClient()`)
37
+ *
38
+ * Static methods are facades that call the singleton instance internally.
39
+ *
40
+ * @example
41
+ * // Application code - use static methods
42
+ * Websocket.Broadcast("lobby", { type: "chat", content: { message: "Hi!" } });
43
+ *
44
+ * // Extension - override instance methods
45
+ * class GameWebsocket extends Websocket {
46
+ * protected createClient(entity: I_WebsocketEntity) {
47
+ * return new GameClient(entity);
48
+ * }
49
+ * }
50
+ */
31
51
  export default class Websocket extends Singleton {
32
52
  protected _channels: WebsocketChannel;
33
53
  protected _clients: Map<string, I_WebsocketClient> = new Map();
@@ -111,7 +131,7 @@ export default class Websocket extends Singleton {
111
131
  if (this._ws_interface_handlers.message) return this._ws_interface_handlers.message(ws, message);
112
132
 
113
133
  ws.send("This is the message from the server: " + message);
114
- Websocket.BraodcastAll({ type: "client.message.received", content: { message } });
134
+ Websocket.BroadCastAll({ type: "client.message.received", content: { message } });
115
135
  };
116
136
 
117
137
  private clientConnected = (ws: ServerWebSocket<WebsocketEntityData>) => {
@@ -124,7 +144,14 @@ export default class Websocket extends Singleton {
124
144
  this._clients.set(client.id, client);
125
145
 
126
146
  client.send({ type: E_WebsocketMessageType.CLIENT_CONNECTED, content: { message: "Welcome to the server", client: client.whoami() } });
127
- global.addMember(client);
147
+
148
+ // Client handles its own joining logic with rollback support
149
+ if (!client.joinChannel(global)) {
150
+ Lib.Warn(`Failed to add client ${client.id} to global channel`);
151
+ }
152
+
153
+ // Mark as fully connected
154
+ client.markConnected();
128
155
 
129
156
  if (this._ws_interface_handlers.open) this._ws_interface_handlers.open(ws);
130
157
  };
@@ -132,15 +159,23 @@ export default class Websocket extends Singleton {
132
159
  private clientDisconnected = (ws: ServerWebSocket<WebsocketEntityData>, code: number, reason: string) => {
133
160
  if (this._options.debug) Lib.Log("Client disconnected", ws.data);
134
161
 
135
- if (this._ws_interface_handlers.close) this._ws_interface_handlers.close(ws, code, reason);
136
-
137
162
  const client = this._clients.get(ws.data.id);
138
163
  if (!client) return;
139
164
 
165
+ // Mark as disconnecting
166
+ client.markDisconnecting();
167
+
168
+ if (this._ws_interface_handlers.close) this._ws_interface_handlers.close(ws, code, reason);
169
+
170
+ // Remove from all channels
140
171
  this._channels.forEach((channel) => {
141
172
  channel.removeMember(client);
142
173
  });
143
174
 
175
+ // Mark as disconnected
176
+ client.markDisconnected();
177
+
178
+ // Remove from registry
144
179
  this._clients.delete(ws.data.id);
145
180
  };
146
181
 
@@ -196,7 +231,7 @@ export default class Websocket extends Singleton {
196
231
  * @param message - The message
197
232
  * @param args - The arguments
198
233
  */
199
- public static BraodcastAll(message: WebsocketStructuredMessage, ...args: any[]) {
234
+ public static BroadCastAll(message: WebsocketStructuredMessage, ...args: any[]) {
200
235
  const ws = this.GetInstance<Websocket>();
201
236
  ws._channels.forEach((channel) => channel.broadcast(message, ...args));
202
237
  }
@@ -297,14 +332,39 @@ export default class Websocket extends Singleton {
297
332
  }
298
333
 
299
334
  /**
300
- * Generate a message
301
- * @returns The generated message
335
+ * Get all connected clients (excluding connecting/disconnecting)
336
+ * @returns Array of connected clients
302
337
  */
303
- public static GenerateMessage(): WebsocketStructuredMessage {
304
- const msg: WebsocketStructuredMessage = {
305
- type: "",
306
- content: {},
338
+ public static GetConnectedClients(): I_WebsocketClient[] {
339
+ const ws = this.GetInstance<Websocket>();
340
+ return Array.from(ws._clients.values()).filter(
341
+ client => client.state === "connected"
342
+ );
343
+ }
344
+
345
+ /**
346
+ * Get client statistics by state
347
+ * @returns Object with counts by state
348
+ */
349
+ public static GetClientStats() {
350
+ const ws = this.GetInstance<Websocket>();
351
+ const stats = {
352
+ total: ws._clients.size,
353
+ connecting: 0,
354
+ connected: 0,
355
+ disconnecting: 0,
356
+ disconnected: 0,
307
357
  };
308
- return msg;
358
+
359
+ for (const client of ws._clients.values()) {
360
+ switch (client.state) {
361
+ case "connecting": stats.connecting++; break;
362
+ case "connected": stats.connected++; break;
363
+ case "disconnecting": stats.disconnecting++; break;
364
+ case "disconnected": stats.disconnected++; break;
365
+ }
366
+ }
367
+
368
+ return stats;
309
369
  }
310
370
  }
@@ -8,7 +8,7 @@ export * from './Channel';
8
8
  export * from './Client';
9
9
  export * from './websocket.enums';
10
10
  export * from './websocket.types';
11
- export { default as Websocket } from './Websocket';
11
+ export { default as GameWebsocket } from './Websocket';
12
12
  export { default as Message } from './Message';
13
13
  export { default as Channel } from './Channel';
14
14
  export { default as Client } from './Client';
@@ -20,3 +20,10 @@ export enum E_WebsocketMessagePriority {
20
20
  MEDIUM = 1,
21
21
  HIGH = 2,
22
22
  }
23
+
24
+ export enum E_ClientState {
25
+ CONNECTING = "connecting",
26
+ CONNECTED = "connected",
27
+ DISCONNECTING = "disconnecting",
28
+ DISCONNECTED = "disconnected",
29
+ }
@@ -1,6 +1,7 @@
1
1
  import { ServerWebSocket, WebSocketHandler } from "bun";
2
2
  import Channel from "./Channel";
3
3
  import Websocket from "./Websocket";
4
+ import { E_ClientState } from "./websocket.enums";
4
5
 
5
6
  export type BunWebsocketMessage = string | Buffer<ArrayBufferLike>;
6
7
 
@@ -102,7 +103,45 @@ export type WebsocketMessage<T extends Record<string, any> = Record<string, any>
102
103
  [key: string]: any;
103
104
  };
104
105
 
105
- export type WebsocketStructuredMessage<T extends Record<string, any> = Record<string, any>> = WebsocketMessage<T> & WebsocketMessageOptions;
106
+ /**
107
+ * Message structure sent over the wire to clients.
108
+ * This is the actual WebSocket payload format - transport options are NOT included.
109
+ */
110
+ export type WebsocketStructuredMessage<T extends Record<string, any> = Record<string, any>> = {
111
+ /** Message type identifier for client-side routing */
112
+ type: string;
113
+
114
+ /** Message payload */
115
+ content: T;
116
+
117
+ /** Channel ID where message originated */
118
+ channel?: string;
119
+
120
+ /** ISO timestamp when message was created */
121
+ timestamp?: string;
122
+
123
+ /** Client information (who sent this) */
124
+ client?: WebsocketEntityData;
125
+
126
+ /** Channel metadata (if included) */
127
+ metadata?: Record<string, string>;
128
+
129
+ /** Message priority for client-side processing */
130
+ priority?: number;
131
+
132
+ /** Expiration timestamp (milliseconds since epoch) */
133
+ expiresAt?: number;
134
+
135
+ /** Any additional custom fields */
136
+ [key: string]: any;
137
+ };
138
+
139
+ /**
140
+ * @deprecated This type incorrectly mixed transport options with wire format.
141
+ * Use WebsocketStructuredMessage for wire format and WebsocketMessageOptions for options.
142
+ * This will be removed in a future version.
143
+ */
144
+ export type deprecated_WebsocketStructuredMessage<T extends Record<string, any> = Record<string, any>> = WebsocketMessage<T> & WebsocketMessageOptions;
106
145
 
107
146
  export type WebsocketEntityId = string;
108
147
  export type WebsocketEntityName = string;
@@ -114,15 +153,21 @@ export interface I_WebsocketEntity extends WebsocketEntityData {
114
153
 
115
154
  export interface I_WebsocketClient extends I_WebsocketEntity {
116
155
  channels: WebsocketChannel<I_WebsocketChannel>;
156
+ state: E_ClientState;
117
157
  send(message: string, options?: WebsocketMessageOptions): void;
118
158
  send(message: WebsocketStructuredMessage): void;
119
159
  subscribe(channel: string): any;
120
- joinChannel(channel: I_WebsocketChannel, send?: boolean): void;
160
+ joinChannel(channel: I_WebsocketChannel, send?: boolean): boolean;
121
161
  leaveChannel(channel: I_WebsocketChannel, send?: boolean): void;
122
162
  joinChannels(channels: I_WebsocketChannel[], send?: boolean): void;
123
163
  leaveChannels(channels?: I_WebsocketChannel[], send?: boolean): void;
124
164
  unsubscribe(channel: string): any;
125
165
  whoami(): WebsocketEntityData;
166
+ canReceiveMessages(): boolean;
167
+ markConnected(): void;
168
+ markDisconnecting(): void;
169
+ markDisconnected(): void;
170
+ getConnectionInfo(): { id: string; name: string; state: E_ClientState; connectedAt?: Date; disconnectedAt?: Date; uptime: number; channelCount: number };
126
171
  }
127
172
 
128
173
  export interface I_WebsocketChannelEntity<T extends Websocket = Websocket> extends WebsocketEntityData {
@@ -133,6 +178,15 @@ export interface I_WebsocketChannelEntity<T extends Websocket = Websocket> exten
133
178
  export type BroadcastOptions = WebsocketMessageOptions & {
134
179
  debug?: boolean;
135
180
  };
181
+
182
+ // Result type for addMember operations
183
+ export type AddMemberResult = { success: true; client: I_WebsocketClient } | { success: false; reason: "full" | "already_member" | "error"; error?: Error };
184
+
185
+ // Options for addMember operations
186
+ export type AddMemberOptions = {
187
+ /** Whether to notify client when channel is full (default: false) */
188
+ notify_when_full?: boolean;
189
+ };
136
190
  export interface I_WebsocketChannel<T extends Websocket = Websocket> extends I_WebsocketChannelEntity<T> {
137
191
  limit: number;
138
192
  members: Map<string, I_WebsocketClient>;
@@ -140,7 +194,8 @@ export interface I_WebsocketChannel<T extends Websocket = Websocket> extends I_W
140
194
  createdAt: Date;
141
195
  broadcast(message: WebsocketStructuredMessage | string, options?: BroadcastOptions): void;
142
196
  hasMember(client: I_WebsocketEntity | string): boolean;
143
- addMember(entity: I_WebsocketClient): I_WebsocketClient | false;
197
+ addMember(entity: I_WebsocketClient, options?: AddMemberOptions): AddMemberResult;
198
+ removeMemberInternal(entity: I_WebsocketClient): void;
144
199
  removeMember(entity: I_WebsocketEntity): I_WebsocketClient | false;
145
200
  getMember(client: I_WebsocketEntity | string): I_WebsocketClient | undefined;
146
201
  getMembers(clients?: string[] | I_WebsocketEntity[]): I_WebsocketClient[];
@@ -26,6 +26,10 @@ export default abstract class BaseEntity {
26
26
 
27
27
  /**
28
28
  * Creates a new entity instance from DTO (infers class from `this`)
29
+ *
30
+ * NOTE: This is a BASE implementation that uses plainToInstance.
31
+ * Derived classes should override if they need custom construction logic.
32
+ *
29
33
  * @param dto - DTO to create entity from
30
34
  */
31
35
  public static FromDto<T extends BaseEntity>(this: ClassConstructor<T>, dto: Dto): T {
@@ -37,6 +41,9 @@ export default abstract class BaseEntity {
37
41
  * Creates multiple entities from DTOs
38
42
  */
39
43
  public static FromDtos<T extends BaseEntity>(this: ClassConstructor<T>, dtos: Dto[]): T[] {
40
- return plainToInstance(this, dtos.map((dto) => dto.toJSON()));
44
+ return plainToInstance(
45
+ this,
46
+ dtos.map((dto) => dto.toJSON()),
47
+ );
41
48
  }
42
49
  }