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.
- 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/dist/utils/BaseEntity.d.ts +4 -0
- package/dist/utils/BaseEntity.js +4 -0
- package/dist/utils/BaseEntity.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
- 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
|
-
|
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,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.
|
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
|
-
|
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
|
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
|
-
*
|
301
|
-
* @returns
|
335
|
+
* Get all connected clients (excluding connecting/disconnecting)
|
336
|
+
* @returns Array of connected clients
|
302
337
|
*/
|
303
|
-
public static
|
304
|
-
const
|
305
|
-
|
306
|
-
|
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
|
-
|
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
|
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';
|
@@ -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[];
|
package/src/utils/BaseEntity.ts
CHANGED
@@ -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(
|
44
|
+
return plainToInstance(
|
45
|
+
this,
|
46
|
+
dtos.map((dto) => dto.toJSON()),
|
47
|
+
);
|
41
48
|
}
|
42
49
|
}
|