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.
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- 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 +34 -4
- package/dist/server/bun/websocket/Websocket.js +70 -12
- package/dist/server/bun/websocket/Websocket.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 +3 -1
- 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 +71 -12
- package/src/server/bun/websocket/websocket.enums.ts +7 -0
- package/src/server/bun/websocket/websocket.types.ts +58 -3
@@ -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
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
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
|
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
|
-
}
|
53
|
-
delete output.client;
|
54
|
-
} */
|
52
|
+
}
|
55
53
|
|
56
54
|
// Include channel metadata if requested
|
57
|
-
|
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
|
-
|
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
|
95
|
-
return
|
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
|
99
|
-
|
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
|
106
|
+
public static Alert(target: I_WebsocketClient, reason: string, client?: WebsocketEntityData): void {
|
105
107
|
target.send(
|
106
|
-
|
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,25 @@ 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
|
+
* MyWebsocket extends Websocket:
|
46
|
+
* protected createClient(entity) {
|
47
|
+
* return new MyCustomClient(entity);
|
48
|
+
* }
|
49
|
+
*/
|
31
50
|
export default class Websocket extends Singleton {
|
32
51
|
protected _channels: WebsocketChannel;
|
33
52
|
protected _clients: Map<string, I_WebsocketClient> = new Map();
|
@@ -111,7 +130,7 @@ export default class Websocket extends Singleton {
|
|
111
130
|
if (this._ws_interface_handlers.message) return this._ws_interface_handlers.message(ws, message);
|
112
131
|
|
113
132
|
ws.send("This is the message from the server: " + message);
|
114
|
-
Websocket.
|
133
|
+
Websocket.BroadCastAll({ type: "client.message.received", content: { message } });
|
115
134
|
};
|
116
135
|
|
117
136
|
private clientConnected = (ws: ServerWebSocket<WebsocketEntityData>) => {
|
@@ -124,7 +143,14 @@ export default class Websocket extends Singleton {
|
|
124
143
|
this._clients.set(client.id, client);
|
125
144
|
|
126
145
|
client.send({ type: E_WebsocketMessageType.CLIENT_CONNECTED, content: { message: "Welcome to the server", client: client.whoami() } });
|
127
|
-
|
146
|
+
|
147
|
+
// Client handles its own joining logic with rollback support
|
148
|
+
if (!client.joinChannel(global)) {
|
149
|
+
Lib.Warn(`Failed to add client ${client.id} to global channel`);
|
150
|
+
}
|
151
|
+
|
152
|
+
// Mark as fully connected
|
153
|
+
client.markConnected();
|
128
154
|
|
129
155
|
if (this._ws_interface_handlers.open) this._ws_interface_handlers.open(ws);
|
130
156
|
};
|
@@ -132,15 +158,23 @@ export default class Websocket extends Singleton {
|
|
132
158
|
private clientDisconnected = (ws: ServerWebSocket<WebsocketEntityData>, code: number, reason: string) => {
|
133
159
|
if (this._options.debug) Lib.Log("Client disconnected", ws.data);
|
134
160
|
|
135
|
-
if (this._ws_interface_handlers.close) this._ws_interface_handlers.close(ws, code, reason);
|
136
|
-
|
137
161
|
const client = this._clients.get(ws.data.id);
|
138
162
|
if (!client) return;
|
139
163
|
|
164
|
+
// Mark as disconnecting
|
165
|
+
client.markDisconnecting();
|
166
|
+
|
167
|
+
if (this._ws_interface_handlers.close) this._ws_interface_handlers.close(ws, code, reason);
|
168
|
+
|
169
|
+
// Remove from all channels
|
140
170
|
this._channels.forEach((channel) => {
|
141
171
|
channel.removeMember(client);
|
142
172
|
});
|
143
173
|
|
174
|
+
// Mark as disconnected
|
175
|
+
client.markDisconnected();
|
176
|
+
|
177
|
+
// Remove from registry
|
144
178
|
this._clients.delete(ws.data.id);
|
145
179
|
};
|
146
180
|
|
@@ -196,7 +230,7 @@ export default class Websocket extends Singleton {
|
|
196
230
|
* @param message - The message
|
197
231
|
* @param args - The arguments
|
198
232
|
*/
|
199
|
-
public static
|
233
|
+
public static BroadCastAll(message: WebsocketStructuredMessage, ...args: any[]) {
|
200
234
|
const ws = this.GetInstance<Websocket>();
|
201
235
|
ws._channels.forEach((channel) => channel.broadcast(message, ...args));
|
202
236
|
}
|
@@ -297,14 +331,39 @@ export default class Websocket extends Singleton {
|
|
297
331
|
}
|
298
332
|
|
299
333
|
/**
|
300
|
-
*
|
301
|
-
* @returns
|
334
|
+
* Get all connected clients (excluding connecting/disconnecting)
|
335
|
+
* @returns Array of connected clients
|
302
336
|
*/
|
303
|
-
public static
|
304
|
-
const
|
305
|
-
|
306
|
-
|
337
|
+
public static GetConnectedClients(): I_WebsocketClient[] {
|
338
|
+
const ws = this.GetInstance<Websocket>();
|
339
|
+
return Array.from(ws._clients.values()).filter(
|
340
|
+
client => client.state === "connected"
|
341
|
+
);
|
342
|
+
}
|
343
|
+
|
344
|
+
/**
|
345
|
+
* Get client statistics by state
|
346
|
+
* @returns Object with counts by state
|
347
|
+
*/
|
348
|
+
public static GetClientStats() {
|
349
|
+
const ws = this.GetInstance<Websocket>();
|
350
|
+
const stats = {
|
351
|
+
total: ws._clients.size,
|
352
|
+
connecting: 0,
|
353
|
+
connected: 0,
|
354
|
+
disconnecting: 0,
|
355
|
+
disconnected: 0,
|
307
356
|
};
|
308
|
-
|
357
|
+
|
358
|
+
for (const client of ws._clients.values()) {
|
359
|
+
switch (client.state) {
|
360
|
+
case "connecting": stats.connecting++; break;
|
361
|
+
case "connected": stats.connected++; break;
|
362
|
+
case "disconnecting": stats.disconnecting++; break;
|
363
|
+
case "disconnected": stats.disconnected++; break;
|
364
|
+
}
|
365
|
+
}
|
366
|
+
|
367
|
+
return stats;
|
309
368
|
}
|
310
369
|
}
|
@@ -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
|
-
|
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):
|
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):
|
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[];
|