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 +1 @@
1
- {"version":3,"file":"websocket.types.js","sourceRoot":"","sources":["../../../../src/server/bun/websocket/websocket.types.ts"],"names":[],"mappings":"","sourcesContent":["import { ServerWebSocket, WebSocketHandler } from \"bun\";\nimport Channel from \"./Channel\";\nimport Websocket from \"./Websocket\";\n\nexport type BunWebsocketMessage = string | Buffer<ArrayBufferLike>;\n\nexport type WebsocketChannel<T extends I_WebsocketChannel = Channel> = Map<string, T>;\nexport type WebsocketClients = Map<string, I_WebsocketClient>;\nexport type WebsocketMessageOptions = {\n\t/**\n\t * Additional data to include in the message content\n\t * If an object is provided, it will be merged with the content\n\t * If a primitive value is provided, it will be added as content.data\n\t */\n\tdata?: any;\n\n\t/**\n\t * Client information to include in the message\n\t * Will be added as content.client\n\t */\n\tclient?: Partial<WebsocketEntityData> & {\n\t\t[key: string]: any;\n\t};\n\n\t/**\n\t * Channel metadata to include in the message\n\t * If true, all metadata will be included\n\t * If an array of strings, only the specified keys will be included\n\t */\n\tincludeMetadata?: boolean | string[];\n\n\t/**\n\t * Client IDs to exclude from receiving the broadcast\n\t * Useful for sending messages to all clients except the sender\n\t */\n\texcludeClients?: string[];\n\n\t/**\n\t * Channel to include in the message\n\t * Defaults to the channel of the message\n\t */\n\tchannel?: string;\n\n\t/**\n\t * Whether to include timestamp in the message\n\t * Defaults to true\n\t */\n\tincludeTimestamp?: boolean;\n\n\t/**\n\t * Custom fields to add to the root of the message\n\t * These will be merged with the message object\n\t */\n\tcustomFields?: Record<string, any>;\n\n\t/**\n\t * Transform function to modify the final message before sending\n\t * This is applied after all other processing\n\t */\n\ttransform?: (message: any) => any;\n\n\t/**\n\t * Priority of the message (higher numbers = higher priority)\n\t * Can be used by clients to determine processing order\n\t */\n\tpriority?: number;\n\n\t/**\n\t * Message expiration time in milliseconds since epoch\n\t * Can be used by clients to ignore outdated messages\n\t */\n\texpiresAt?: number;\n\n\t/**\n\t * Metadata to include in the message\n\t * If an array of strings, only the specified keys will be included\n\t */\n\tmetadata?: boolean | string[] | Record<string, string>;\n};\n\nexport type WebsocketMessage<T extends Record<string, any> = Record<string, any>> = {\n\t/**\n\t * Message type identifier used for client-side routing\n\t */\n\ttype: string;\n\t/**\n\t * Message content - can be any data structure\n\t * If a string is provided, it will be wrapped in {message: string}\n\t */\n\tcontent: T;\n\t/**\n\t * Channel ID\n\t */\n\tchannel?: string;\n\t/**\n\t * Timestamp of the message\n\t */\n\ttimestamp?: string;\n\t/**\n\t * Any additional custom fields\n\t */\n\t[key: string]: any;\n};\n\nexport type WebsocketStructuredMessage<T extends Record<string, any> = Record<string, any>> = WebsocketMessage<T> & WebsocketMessageOptions;\n\nexport type WebsocketEntityId = string;\nexport type WebsocketEntityName = string;\nexport type WebsocketEntityData = { id: WebsocketEntityId; name: WebsocketEntityName };\n\nexport interface I_WebsocketEntity extends WebsocketEntityData {\n\tws: ServerWebSocket<WebsocketEntityData>;\n}\n\nexport interface I_WebsocketClient extends I_WebsocketEntity {\n\tchannels: WebsocketChannel<I_WebsocketChannel>;\n\tsend(message: string, options?: WebsocketMessageOptions): void;\n\tsend(message: WebsocketStructuredMessage): void;\n\tsubscribe(channel: string): any;\n\tjoinChannel(channel: I_WebsocketChannel, send?: boolean): void;\n\tleaveChannel(channel: I_WebsocketChannel, send?: boolean): void;\n\tjoinChannels(channels: I_WebsocketChannel[], send?: boolean): void;\n\tleaveChannels(channels?: I_WebsocketChannel[], send?: boolean): void;\n\tunsubscribe(channel: string): any;\n\twhoami(): WebsocketEntityData;\n}\n\nexport interface I_WebsocketChannelEntity<T extends Websocket = Websocket> extends WebsocketEntityData {\n\tws: T;\n}\n\n// New types for the broadcast method\nexport type BroadcastOptions = WebsocketMessageOptions & {\n\tdebug?: boolean;\n};\nexport interface I_WebsocketChannel<T extends Websocket = Websocket> extends I_WebsocketChannelEntity<T> {\n\tlimit: number;\n\tmembers: Map<string, I_WebsocketClient>;\n\tmetadata: Record<string, string>;\n\tcreatedAt: Date;\n\tbroadcast(message: WebsocketStructuredMessage | string, options?: BroadcastOptions): void;\n\thasMember(client: I_WebsocketEntity | string): boolean;\n\taddMember(entity: I_WebsocketClient): I_WebsocketClient | false;\n\tremoveMember(entity: I_WebsocketEntity): I_WebsocketClient | false;\n\tgetMember(client: I_WebsocketEntity | string): I_WebsocketClient | undefined;\n\tgetMembers(clients?: string[] | I_WebsocketEntity[]): I_WebsocketClient[];\n\tgetMetadata(): Record<string, string>;\n\tgetCreatedAt(): Date;\n\tgetId(): string;\n\tgetSize(): number;\n\tgetLimit(): number;\n\tgetName(): string;\n\tcanAddMember(): boolean;\n}\n\n/**\n * Interface for implementing custom WebSocket behavior.\n *\n * @interface I_WebsocketInterface\n *\n * @property {Function} setup - Initializes the WebSocket handler with channels and clients\n *\n * The interface supports three optional handler methods:\n *\n * - `message`: Custom message handler that replaces the default handler\n * - `open`: Connection handler that runs after the default open handler\n * - `close`: Disconnection handler that runs before the default close handler\n */\nexport type WebsocketInterfaceHandlers = Partial<WebSocketHandler<WebsocketEntityData>>;\n\n/**\n * Interface for implementing custom WebSocket behavior.\n *\n * @interface I_WebsocketInterface\n *\n * @property {Function} setup - Initializes the WebSocket handler with channels and clients\n *\n * The interface supports three optional handler methods:\n *\n * - `message`: Custom message handler that replaces the default handler\n * - `open`: Connection handler that runs after the default open handler\n * - `close`: Disconnection handler that runs before the default close handler\n */\nexport interface I_WebsocketInterface {\n\thandlers: (channels: WebsocketChannel, clients: WebsocketClients) => WebsocketInterfaceHandlers;\n}\n"]}
1
+ {"version":3,"file":"websocket.types.js","sourceRoot":"","sources":["../../../../src/server/bun/websocket/websocket.types.ts"],"names":[],"mappings":"","sourcesContent":["import { ServerWebSocket, WebSocketHandler } from \"bun\";\nimport Channel from \"./Channel\";\nimport Websocket from \"./Websocket\";\nimport { E_ClientState } from \"./websocket.enums\";\n\nexport type BunWebsocketMessage = string | Buffer<ArrayBufferLike>;\n\nexport type WebsocketChannel<T extends I_WebsocketChannel = Channel> = Map<string, T>;\nexport type WebsocketClients = Map<string, I_WebsocketClient>;\nexport type WebsocketMessageOptions = {\n\t/**\n\t * Additional data to include in the message content\n\t * If an object is provided, it will be merged with the content\n\t * If a primitive value is provided, it will be added as content.data\n\t */\n\tdata?: any;\n\n\t/**\n\t * Client information to include in the message\n\t * Will be added as content.client\n\t */\n\tclient?: Partial<WebsocketEntityData> & {\n\t\t[key: string]: any;\n\t};\n\n\t/**\n\t * Channel metadata to include in the message\n\t * If true, all metadata will be included\n\t * If an array of strings, only the specified keys will be included\n\t */\n\tincludeMetadata?: boolean | string[];\n\n\t/**\n\t * Client IDs to exclude from receiving the broadcast\n\t * Useful for sending messages to all clients except the sender\n\t */\n\texcludeClients?: string[];\n\n\t/**\n\t * Channel to include in the message\n\t * Defaults to the channel of the message\n\t */\n\tchannel?: string;\n\n\t/**\n\t * Whether to include timestamp in the message\n\t * Defaults to true\n\t */\n\tincludeTimestamp?: boolean;\n\n\t/**\n\t * Custom fields to add to the root of the message\n\t * These will be merged with the message object\n\t */\n\tcustomFields?: Record<string, any>;\n\n\t/**\n\t * Transform function to modify the final message before sending\n\t * This is applied after all other processing\n\t */\n\ttransform?: (message: any) => any;\n\n\t/**\n\t * Priority of the message (higher numbers = higher priority)\n\t * Can be used by clients to determine processing order\n\t */\n\tpriority?: number;\n\n\t/**\n\t * Message expiration time in milliseconds since epoch\n\t * Can be used by clients to ignore outdated messages\n\t */\n\texpiresAt?: number;\n\n\t/**\n\t * Metadata to include in the message\n\t * If an array of strings, only the specified keys will be included\n\t */\n\tmetadata?: boolean | string[] | Record<string, string>;\n};\n\nexport type WebsocketMessage<T extends Record<string, any> = Record<string, any>> = {\n\t/**\n\t * Message type identifier used for client-side routing\n\t */\n\ttype: string;\n\t/**\n\t * Message content - can be any data structure\n\t * If a string is provided, it will be wrapped in {message: string}\n\t */\n\tcontent: T;\n\t/**\n\t * Channel ID\n\t */\n\tchannel?: string;\n\t/**\n\t * Timestamp of the message\n\t */\n\ttimestamp?: string;\n\t/**\n\t * Any additional custom fields\n\t */\n\t[key: string]: any;\n};\n\n/**\n * Message structure sent over the wire to clients.\n * This is the actual WebSocket payload format - transport options are NOT included.\n */\nexport type WebsocketStructuredMessage<T extends Record<string, any> = Record<string, any>> = {\n\t/** Message type identifier for client-side routing */\n\ttype: string;\n\n\t/** Message payload */\n\tcontent: T;\n\n\t/** Channel ID where message originated */\n\tchannel?: string;\n\n\t/** ISO timestamp when message was created */\n\ttimestamp?: string;\n\n\t/** Client information (who sent this) */\n\tclient?: WebsocketEntityData;\n\n\t/** Channel metadata (if included) */\n\tmetadata?: Record<string, string>;\n\n\t/** Message priority for client-side processing */\n\tpriority?: number;\n\n\t/** Expiration timestamp (milliseconds since epoch) */\n\texpiresAt?: number;\n\n\t/** Any additional custom fields */\n\t[key: string]: any;\n};\n\n/**\n * @deprecated This type incorrectly mixed transport options with wire format.\n * Use WebsocketStructuredMessage for wire format and WebsocketMessageOptions for options.\n * This will be removed in a future version.\n */\nexport type deprecated_WebsocketStructuredMessage<T extends Record<string, any> = Record<string, any>> = WebsocketMessage<T> & WebsocketMessageOptions;\n\nexport type WebsocketEntityId = string;\nexport type WebsocketEntityName = string;\nexport type WebsocketEntityData = { id: WebsocketEntityId; name: WebsocketEntityName };\n\nexport interface I_WebsocketEntity extends WebsocketEntityData {\n\tws: ServerWebSocket<WebsocketEntityData>;\n}\n\nexport interface I_WebsocketClient extends I_WebsocketEntity {\n\tchannels: WebsocketChannel<I_WebsocketChannel>;\n\tstate: E_ClientState;\n\tsend(message: string, options?: WebsocketMessageOptions): void;\n\tsend(message: WebsocketStructuredMessage): void;\n\tsubscribe(channel: string): any;\n\tjoinChannel(channel: I_WebsocketChannel, send?: boolean): boolean;\n\tleaveChannel(channel: I_WebsocketChannel, send?: boolean): void;\n\tjoinChannels(channels: I_WebsocketChannel[], send?: boolean): void;\n\tleaveChannels(channels?: I_WebsocketChannel[], send?: boolean): void;\n\tunsubscribe(channel: string): any;\n\twhoami(): WebsocketEntityData;\n\tcanReceiveMessages(): boolean;\n\tmarkConnected(): void;\n\tmarkDisconnecting(): void;\n\tmarkDisconnected(): void;\n\tgetConnectionInfo(): { id: string; name: string; state: E_ClientState; connectedAt?: Date; disconnectedAt?: Date; uptime: number; channelCount: number };\n}\n\nexport interface I_WebsocketChannelEntity<T extends Websocket = Websocket> extends WebsocketEntityData {\n\tws: T;\n}\n\n// New types for the broadcast method\nexport type BroadcastOptions = WebsocketMessageOptions & {\n\tdebug?: boolean;\n};\n\n// Result type for addMember operations\nexport type AddMemberResult = { success: true; client: I_WebsocketClient } | { success: false; reason: \"full\" | \"already_member\" | \"error\"; error?: Error };\n\n// Options for addMember operations\nexport type AddMemberOptions = {\n\t/** Whether to notify client when channel is full (default: false) */\n\tnotify_when_full?: boolean;\n};\nexport interface I_WebsocketChannel<T extends Websocket = Websocket> extends I_WebsocketChannelEntity<T> {\n\tlimit: number;\n\tmembers: Map<string, I_WebsocketClient>;\n\tmetadata: Record<string, string>;\n\tcreatedAt: Date;\n\tbroadcast(message: WebsocketStructuredMessage | string, options?: BroadcastOptions): void;\n\thasMember(client: I_WebsocketEntity | string): boolean;\n\taddMember(entity: I_WebsocketClient, options?: AddMemberOptions): AddMemberResult;\n\tremoveMemberInternal(entity: I_WebsocketClient): void;\n\tremoveMember(entity: I_WebsocketEntity): I_WebsocketClient | false;\n\tgetMember(client: I_WebsocketEntity | string): I_WebsocketClient | undefined;\n\tgetMembers(clients?: string[] | I_WebsocketEntity[]): I_WebsocketClient[];\n\tgetMetadata(): Record<string, string>;\n\tgetCreatedAt(): Date;\n\tgetId(): string;\n\tgetSize(): number;\n\tgetLimit(): number;\n\tgetName(): string;\n\tcanAddMember(): boolean;\n}\n\n/**\n * Interface for implementing custom WebSocket behavior.\n *\n * @interface I_WebsocketInterface\n *\n * @property {Function} setup - Initializes the WebSocket handler with channels and clients\n *\n * The interface supports three optional handler methods:\n *\n * - `message`: Custom message handler that replaces the default handler\n * - `open`: Connection handler that runs after the default open handler\n * - `close`: Disconnection handler that runs before the default close handler\n */\nexport type WebsocketInterfaceHandlers = Partial<WebSocketHandler<WebsocketEntityData>>;\n\n/**\n * Interface for implementing custom WebSocket behavior.\n *\n * @interface I_WebsocketInterface\n *\n * @property {Function} setup - Initializes the WebSocket handler with channels and clients\n *\n * The interface supports three optional handler methods:\n *\n * - `message`: Custom message handler that replaces the default handler\n * - `open`: Connection handler that runs after the default open handler\n * - `close`: Disconnection handler that runs before the default close handler\n */\nexport interface I_WebsocketInterface {\n\thandlers: (channels: WebsocketChannel, clients: WebsocketClients) => WebsocketInterfaceHandlers;\n}\n"]}
@@ -17,6 +17,10 @@ export default abstract class BaseEntity {
17
17
  update<T extends BaseEntity>(this: T, data: Partial<T>): T;
18
18
  /**
19
19
  * Creates a new entity instance from DTO (infers class from `this`)
20
+ *
21
+ * NOTE: This is a BASE implementation that uses plainToInstance.
22
+ * Derived classes should override if they need custom construction logic.
23
+ *
20
24
  * @param dto - DTO to create entity from
21
25
  */
22
26
  static FromDto<T extends BaseEntity>(this: ClassConstructor<T>, dto: Dto): T;
@@ -17,6 +17,10 @@ export default class BaseEntity {
17
17
  }
18
18
  /**
19
19
  * Creates a new entity instance from DTO (infers class from `this`)
20
+ *
21
+ * NOTE: This is a BASE implementation that uses plainToInstance.
22
+ * Derived classes should override if they need custom construction logic.
23
+ *
20
24
  * @param dto - DTO to create entity from
21
25
  */
22
26
  static FromDto(dto) {
@@ -1 +1 @@
1
- {"version":3,"file":"BaseEntity.js","sourceRoot":"","sources":["../../src/utils/BaseEntity.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoB,eAAe,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAGvF,MAAM,CAAC,OAAO,OAAgB,UAAU;IACvC;;OAEG;IACI,MAAM;QACZ,OAAO,eAAe,CAAC,IAAI,CAAM,CAAC;IACnC,CAAC;IAOD;;;;OAIG;IACI,MAAM,CAAgC,IAAgB;QAC5D,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;QACtF,OAAO,OAAO,CAAC;IAChB,CAAC;IAED;;;OAGG;IACI,MAAM,CAAC,OAAO,CAAkD,GAAQ;QAC9E,MAAM,QAAQ,GAAG,eAAe,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;QACrD,OAAO,QAAQ,CAAC;IACjB,CAAC;IAED;;OAEG;IACI,MAAM,CAAC,QAAQ,CAAkD,IAAW;QAClF,OAAO,eAAe,CAAC,IAAI,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IAC/D,CAAC;CACD","sourcesContent":["import { ClassConstructor, instanceToPlain, plainToInstance } from \"class-transformer\";\nimport { Dto } from \"./BaseDto\";\n\nexport default abstract class BaseEntity {\n\t/**\n\t * Converts entity to plain object\n\t */\n\tpublic toJSON<T = Record<string, unknown>>(): T {\n\t\treturn instanceToPlain(this) as T;\n\t}\n\n\t/**\n\t * Abstract method - entities must define how to convert to DTO\n\t */\n\tpublic abstract toDto(): Dto;\n\n\t/**\n\t * Updates entity with partial data (immutable - returns new instance)\n\t * @param data - Partial data to update\n\t * @param validate - Whether to validate after update (default: true)\n\t */\n\tpublic update<T extends BaseEntity>(this: T, data: Partial<T>): T {\n\t\tconst updated = Object.assign(Object.create(Object.getPrototypeOf(this)), this, data);\n\t\treturn updated;\n\t}\n\n\t/**\n\t * Creates a new entity instance from DTO (infers class from `this`)\n\t * @param dto - DTO to create entity from\n\t */\n\tpublic static FromDto<T extends BaseEntity>(this: ClassConstructor<T>, dto: Dto): T {\n\t\tconst instance = plainToInstance(this, dto.toJSON());\n\t\treturn instance;\n\t}\n\n\t/**\n\t * Creates multiple entities from DTOs\n\t */\n\tpublic static FromDtos<T extends BaseEntity>(this: ClassConstructor<T>, dtos: Dto[]): T[] {\n\t\treturn plainToInstance(this, dtos.map((dto) => dto.toJSON()));\n\t}\n}\n"]}
1
+ {"version":3,"file":"BaseEntity.js","sourceRoot":"","sources":["../../src/utils/BaseEntity.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoB,eAAe,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAGvF,MAAM,CAAC,OAAO,OAAgB,UAAU;IACvC;;OAEG;IACI,MAAM;QACZ,OAAO,eAAe,CAAC,IAAI,CAAM,CAAC;IACnC,CAAC;IAOD;;;;OAIG;IACI,MAAM,CAAgC,IAAgB;QAC5D,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;QACtF,OAAO,OAAO,CAAC;IAChB,CAAC;IAED;;;;;;;OAOG;IACI,MAAM,CAAC,OAAO,CAAkD,GAAQ;QAC9E,MAAM,QAAQ,GAAG,eAAe,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;QACrD,OAAO,QAAQ,CAAC;IACjB,CAAC;IAED;;OAEG;IACI,MAAM,CAAC,QAAQ,CAAkD,IAAW;QAClF,OAAO,eAAe,CACrB,IAAI,EACJ,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,MAAM,EAAE,CAAC,CAC/B,CAAC;IACH,CAAC;CACD","sourcesContent":["import { ClassConstructor, instanceToPlain, plainToInstance } from \"class-transformer\";\nimport { Dto } from \"./BaseDto\";\n\nexport default abstract class BaseEntity {\n\t/**\n\t * Converts entity to plain object\n\t */\n\tpublic toJSON<T = Record<string, unknown>>(): T {\n\t\treturn instanceToPlain(this) as T;\n\t}\n\n\t/**\n\t * Abstract method - entities must define how to convert to DTO\n\t */\n\tpublic abstract toDto(): Dto;\n\n\t/**\n\t * Updates entity with partial data (immutable - returns new instance)\n\t * @param data - Partial data to update\n\t * @param validate - Whether to validate after update (default: true)\n\t */\n\tpublic update<T extends BaseEntity>(this: T, data: Partial<T>): T {\n\t\tconst updated = Object.assign(Object.create(Object.getPrototypeOf(this)), this, data);\n\t\treturn updated;\n\t}\n\n\t/**\n\t * Creates a new entity instance from DTO (infers class from `this`)\n\t *\n\t * NOTE: This is a BASE implementation that uses plainToInstance.\n\t * Derived classes should override if they need custom construction logic.\n\t *\n\t * @param dto - DTO to create entity from\n\t */\n\tpublic static FromDto<T extends BaseEntity>(this: ClassConstructor<T>, dto: Dto): T {\n\t\tconst instance = plainToInstance(this, dto.toJSON());\n\t\treturn instance;\n\t}\n\n\t/**\n\t * Creates multiple entities from DTOs\n\t */\n\tpublic static FromDtos<T extends BaseEntity>(this: ClassConstructor<T>, dtos: Dto[]): T[] {\n\t\treturn plainToInstance(\n\t\t\tthis,\n\t\t\tdtos.map((dto) => dto.toJSON()),\n\t\t);\n\t}\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "topsyde-utils",
3
- "version": "1.0.204",
3
+ "version": "1.0.206",
4
4
  "description": "A bundle of TypeScript utility classes and functions",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -83,7 +83,7 @@ describe("Websocket Tests", () => {
83
83
  content: { text: "Broadcast to all channels" },
84
84
  };
85
85
 
86
- _Websocket.BraodcastAll(message);
86
+ _Websocket.BroadCastAll(message);
87
87
  // No assertion needed, just checking it doesn't throw
88
88
  });
89
89
 
@@ -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
@@ -45,7 +45,7 @@ export { default as Service } from "./server/service";
45
45
  export { default as Database } from "./server/base/base.database";
46
46
  export { default as Router } from "./server/bun/router/router";
47
47
  export { default as Router_Internal } from "./server/bun/router/router.internal";
48
- export { default as Websocket } from "./server/bun/websocket/Websocket";
48
+ export { default as GameWebsocket } from "./server/bun/websocket/Websocket";
49
49
  export { default as Message } from "./server/bun/websocket/Message";
50
50
  export { default as Channel } from "./server/bun/websocket/Channel";
51
51
  export { default as Client } from "./server/bun/websocket/Client";
@@ -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 {