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
@@ -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
@@ -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 {
|