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.
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/server/bun/websocket/Channel.d.ts +25 -3
- package/dist/server/bun/websocket/Channel.js +80 -26
- package/dist/server/bun/websocket/Channel.js.map +1 -1
- package/dist/server/bun/websocket/Client.d.ts +34 -1
- package/dist/server/bun/websocket/Client.js +95 -18
- package/dist/server/bun/websocket/Client.js.map +1 -1
- package/dist/server/bun/websocket/Message.d.ts +6 -10
- package/dist/server/bun/websocket/Message.js +31 -32
- package/dist/server/bun/websocket/Message.js.map +1 -1
- package/dist/server/bun/websocket/Websocket.d.ts +35 -4
- package/dist/server/bun/websocket/Websocket.js +71 -12
- package/dist/server/bun/websocket/Websocket.js.map +1 -1
- package/dist/server/bun/websocket/index.d.ts +1 -1
- package/dist/server/bun/websocket/index.js +1 -1
- package/dist/server/bun/websocket/index.js.map +1 -1
- package/dist/server/bun/websocket/websocket.enums.d.ts +6 -0
- package/dist/server/bun/websocket/websocket.enums.js +7 -0
- package/dist/server/bun/websocket/websocket.enums.js.map +1 -1
- package/dist/server/bun/websocket/websocket.types.d.ts +60 -3
- package/dist/server/bun/websocket/websocket.types.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/app.test.ts +1 -1
- package/src/__tests__/singleton.test.ts +6 -4
- package/src/index.ts +4 -2
- package/src/server/bun/websocket/Channel.ts +89 -36
- package/src/server/bun/websocket/Client.ts +109 -19
- package/src/server/bun/websocket/ISSUES.md +1175 -0
- package/src/server/bun/websocket/Message.ts +36 -49
- package/src/server/bun/websocket/Websocket.ts +72 -12
- package/src/server/bun/websocket/index.ts +1 -1
- package/src/server/bun/websocket/websocket.enums.ts +7 -0
- package/src/server/bun/websocket/websocket.types.ts +58 -3
@@ -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):
|
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"]}
|
package/package.json
CHANGED
@@ -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:
|
296
|
-
|
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.
|
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
|
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
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
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
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
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
|
-
|
65
|
-
|
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
|
89
|
-
this.members.
|
90
|
-
|
91
|
-
|
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
|
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
|
-
|
65
|
-
|
66
|
-
if (
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
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
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
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 {
|