@onebun/core 0.1.1 → 0.1.2
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/README.md +233 -0
- package/package.json +1 -1
- package/src/application.test.ts +119 -0
- package/src/application.ts +112 -5
- package/src/docs-examples.test.ts +753 -0
- package/src/index.ts +96 -0
- package/src/module.ts +10 -4
- package/src/redis-client.ts +502 -0
- package/src/shared-redis.ts +231 -0
- package/src/types.ts +50 -0
- package/src/ws-base-gateway.test.ts +479 -0
- package/src/ws-base-gateway.ts +514 -0
- package/src/ws-client.test.ts +511 -0
- package/src/ws-client.ts +628 -0
- package/src/ws-client.types.ts +129 -0
- package/src/ws-decorators.test.ts +331 -0
- package/src/ws-decorators.ts +417 -0
- package/src/ws-guards.test.ts +334 -0
- package/src/ws-guards.ts +298 -0
- package/src/ws-handler.ts +658 -0
- package/src/ws-integration.test.ts +517 -0
- package/src/ws-pattern-matcher.test.ts +152 -0
- package/src/ws-pattern-matcher.ts +240 -0
- package/src/ws-service-definition.ts +223 -0
- package/src/ws-socketio-protocol.test.ts +344 -0
- package/src/ws-socketio-protocol.ts +567 -0
- package/src/ws-storage-memory.test.ts +246 -0
- package/src/ws-storage-memory.ts +222 -0
- package/src/ws-storage-redis.ts +302 -0
- package/src/ws-storage.ts +210 -0
- package/src/ws.types.ts +342 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-Memory WebSocket Storage Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
describe,
|
|
7
|
+
it,
|
|
8
|
+
expect,
|
|
9
|
+
beforeEach,
|
|
10
|
+
} from 'bun:test';
|
|
11
|
+
|
|
12
|
+
import type { WsClientData, WsRoom } from './ws.types';
|
|
13
|
+
|
|
14
|
+
import { InMemoryWsStorage } from './ws-storage-memory';
|
|
15
|
+
|
|
16
|
+
describe('InMemoryWsStorage', () => {
|
|
17
|
+
let storage: InMemoryWsStorage;
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
storage = new InMemoryWsStorage();
|
|
21
|
+
await storage.clear();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('client operations', () => {
|
|
25
|
+
const createClient = (id: string): WsClientData => ({
|
|
26
|
+
id,
|
|
27
|
+
rooms: [],
|
|
28
|
+
connectedAt: Date.now(),
|
|
29
|
+
auth: null,
|
|
30
|
+
metadata: {},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should add and retrieve a client', async () => {
|
|
34
|
+
const client = createClient('client1');
|
|
35
|
+
await storage.addClient(client);
|
|
36
|
+
|
|
37
|
+
const retrieved = await storage.getClient('client1');
|
|
38
|
+
expect(retrieved).not.toBeNull();
|
|
39
|
+
expect(retrieved?.id).toBe('client1');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should return null for non-existent client', async () => {
|
|
43
|
+
const result = await storage.getClient('non-existent');
|
|
44
|
+
expect(result).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should update client data', async () => {
|
|
48
|
+
const client = createClient('client1');
|
|
49
|
+
await storage.addClient(client);
|
|
50
|
+
|
|
51
|
+
await storage.updateClient('client1', { metadata: { custom: 'value' } });
|
|
52
|
+
|
|
53
|
+
const updated = await storage.getClient('client1');
|
|
54
|
+
expect(updated?.metadata).toEqual({ custom: 'value' });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should remove a client', async () => {
|
|
58
|
+
const client = createClient('client1');
|
|
59
|
+
await storage.addClient(client);
|
|
60
|
+
|
|
61
|
+
await storage.removeClient('client1');
|
|
62
|
+
|
|
63
|
+
const result = await storage.getClient('client1');
|
|
64
|
+
expect(result).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should get all clients', async () => {
|
|
68
|
+
await storage.addClient(createClient('client1'));
|
|
69
|
+
await storage.addClient(createClient('client2'));
|
|
70
|
+
|
|
71
|
+
const clients = await storage.getAllClients();
|
|
72
|
+
expect(clients).toHaveLength(2);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should get client count', async () => {
|
|
76
|
+
await storage.addClient(createClient('client1'));
|
|
77
|
+
await storage.addClient(createClient('client2'));
|
|
78
|
+
|
|
79
|
+
const count = await storage.getClientCount();
|
|
80
|
+
expect(count).toBe(2);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('room operations', () => {
|
|
85
|
+
it('should create and retrieve a room', async () => {
|
|
86
|
+
const room: WsRoom = { name: 'test-room', clientIds: [] };
|
|
87
|
+
await storage.createRoom(room);
|
|
88
|
+
|
|
89
|
+
const retrieved = await storage.getRoom('test-room');
|
|
90
|
+
expect(retrieved).not.toBeNull();
|
|
91
|
+
expect(retrieved?.name).toBe('test-room');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should delete a room', async () => {
|
|
95
|
+
const room: WsRoom = { name: 'test-room', clientIds: [] };
|
|
96
|
+
await storage.createRoom(room);
|
|
97
|
+
|
|
98
|
+
await storage.deleteRoom('test-room');
|
|
99
|
+
|
|
100
|
+
const result = await storage.getRoom('test-room');
|
|
101
|
+
expect(result).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should get all rooms', async () => {
|
|
105
|
+
await storage.createRoom({ name: 'room1', clientIds: [] });
|
|
106
|
+
await storage.createRoom({ name: 'room2', clientIds: [] });
|
|
107
|
+
|
|
108
|
+
const rooms = await storage.getAllRooms();
|
|
109
|
+
expect(rooms).toHaveLength(2);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should get rooms by pattern', async () => {
|
|
113
|
+
await storage.createRoom({ name: 'chat:general', clientIds: [] });
|
|
114
|
+
await storage.createRoom({ name: 'chat:private', clientIds: [] });
|
|
115
|
+
await storage.createRoom({ name: 'game:lobby', clientIds: [] });
|
|
116
|
+
|
|
117
|
+
const chatRooms = await storage.getRoomsByPattern('chat:*');
|
|
118
|
+
expect(chatRooms).toHaveLength(2);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should update room metadata', async () => {
|
|
122
|
+
await storage.createRoom({ name: 'test-room', clientIds: [] });
|
|
123
|
+
|
|
124
|
+
await storage.updateRoomMetadata('test-room', { topic: 'General Discussion' });
|
|
125
|
+
|
|
126
|
+
const room = await storage.getRoom('test-room');
|
|
127
|
+
expect(room?.metadata).toEqual({ topic: 'General Discussion' });
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('room membership', () => {
|
|
132
|
+
const createClient = (id: string): WsClientData => ({
|
|
133
|
+
id,
|
|
134
|
+
rooms: [],
|
|
135
|
+
connectedAt: Date.now(),
|
|
136
|
+
auth: null,
|
|
137
|
+
metadata: {},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should add client to room', async () => {
|
|
141
|
+
await storage.addClient(createClient('client1'));
|
|
142
|
+
await storage.addClientToRoom('client1', 'test-room');
|
|
143
|
+
|
|
144
|
+
const clientIds = await storage.getClientsInRoom('test-room');
|
|
145
|
+
expect(clientIds).toContain('client1');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should create room automatically when adding client', async () => {
|
|
149
|
+
await storage.addClient(createClient('client1'));
|
|
150
|
+
await storage.addClientToRoom('client1', 'auto-room');
|
|
151
|
+
|
|
152
|
+
const room = await storage.getRoom('auto-room');
|
|
153
|
+
expect(room).not.toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should update client rooms list', async () => {
|
|
157
|
+
await storage.addClient(createClient('client1'));
|
|
158
|
+
await storage.addClientToRoom('client1', 'room1');
|
|
159
|
+
await storage.addClientToRoom('client1', 'room2');
|
|
160
|
+
|
|
161
|
+
const client = await storage.getClient('client1');
|
|
162
|
+
expect(client?.rooms).toContain('room1');
|
|
163
|
+
expect(client?.rooms).toContain('room2');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should remove client from room', async () => {
|
|
167
|
+
await storage.addClient(createClient('client1'));
|
|
168
|
+
await storage.addClientToRoom('client1', 'test-room');
|
|
169
|
+
|
|
170
|
+
await storage.removeClientFromRoom('client1', 'test-room');
|
|
171
|
+
|
|
172
|
+
const clientIds = await storage.getClientsInRoom('test-room');
|
|
173
|
+
expect(clientIds).not.toContain('client1');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should delete empty room after client leaves', async () => {
|
|
177
|
+
await storage.addClient(createClient('client1'));
|
|
178
|
+
await storage.addClientToRoom('client1', 'temp-room');
|
|
179
|
+
|
|
180
|
+
await storage.removeClientFromRoom('client1', 'temp-room');
|
|
181
|
+
|
|
182
|
+
const room = await storage.getRoom('temp-room');
|
|
183
|
+
expect(room).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should get rooms for a client', async () => {
|
|
187
|
+
await storage.addClient(createClient('client1'));
|
|
188
|
+
await storage.addClientToRoom('client1', 'room1');
|
|
189
|
+
await storage.addClientToRoom('client1', 'room2');
|
|
190
|
+
|
|
191
|
+
const rooms = await storage.getRoomsForClient('client1');
|
|
192
|
+
expect(rooms).toHaveLength(2);
|
|
193
|
+
expect(rooms).toContain('room1');
|
|
194
|
+
expect(rooms).toContain('room2');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should remove client from all rooms', async () => {
|
|
198
|
+
await storage.addClient(createClient('client1'));
|
|
199
|
+
await storage.addClientToRoom('client1', 'room1');
|
|
200
|
+
await storage.addClientToRoom('client1', 'room2');
|
|
201
|
+
|
|
202
|
+
await storage.removeClientFromAllRooms('client1');
|
|
203
|
+
|
|
204
|
+
const rooms = await storage.getRoomsForClient('client1');
|
|
205
|
+
expect(rooms).toHaveLength(0);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should remove client from all rooms when client is removed', async () => {
|
|
209
|
+
await storage.addClient(createClient('client1'));
|
|
210
|
+
await storage.addClient(createClient('client2'));
|
|
211
|
+
await storage.addClientToRoom('client1', 'shared-room');
|
|
212
|
+
await storage.addClientToRoom('client2', 'shared-room');
|
|
213
|
+
|
|
214
|
+
await storage.removeClient('client1');
|
|
215
|
+
|
|
216
|
+
const clientIds = await storage.getClientsInRoom('shared-room');
|
|
217
|
+
expect(clientIds).not.toContain('client1');
|
|
218
|
+
expect(clientIds).toContain('client2');
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('lifecycle', () => {
|
|
223
|
+
it('should clear all data', async () => {
|
|
224
|
+
const client: WsClientData = {
|
|
225
|
+
id: 'client1',
|
|
226
|
+
rooms: [],
|
|
227
|
+
connectedAt: Date.now(),
|
|
228
|
+
auth: null,
|
|
229
|
+
metadata: {},
|
|
230
|
+
};
|
|
231
|
+
await storage.addClient(client);
|
|
232
|
+
await storage.createRoom({ name: 'test-room', clientIds: [] });
|
|
233
|
+
|
|
234
|
+
await storage.clear();
|
|
235
|
+
|
|
236
|
+
expect(await storage.getClientCount()).toBe(0);
|
|
237
|
+
expect(await storage.getAllRooms()).toHaveLength(0);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should return stats', () => {
|
|
241
|
+
const stats = storage.getStats();
|
|
242
|
+
expect(stats).toHaveProperty('clientCount');
|
|
243
|
+
expect(stats).toHaveProperty('roomCount');
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-Memory WebSocket Storage
|
|
3
|
+
*
|
|
4
|
+
* Simple in-memory implementation of WsStorageAdapter.
|
|
5
|
+
* Suitable for single-instance deployments.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { WsStorageAdapter } from './ws-storage';
|
|
9
|
+
import type { WsClientData, WsRoom } from './ws.types';
|
|
10
|
+
|
|
11
|
+
import { isPatternMatch } from './ws-pattern-matcher';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* In-memory storage for WebSocket clients and rooms
|
|
15
|
+
*/
|
|
16
|
+
export class InMemoryWsStorage implements WsStorageAdapter {
|
|
17
|
+
private clients = new Map<string, WsClientData>();
|
|
18
|
+
private rooms = new Map<string, WsRoom>();
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Client Operations
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
async addClient(client: WsClientData): Promise<void> {
|
|
25
|
+
this.clients.set(client.id, { ...client });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async removeClient(clientId: string): Promise<void> {
|
|
29
|
+
// Remove from all rooms first
|
|
30
|
+
await this.removeClientFromAllRooms(clientId);
|
|
31
|
+
// Then remove the client
|
|
32
|
+
this.clients.delete(clientId);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async getClient(clientId: string): Promise<WsClientData | null> {
|
|
36
|
+
const client = this.clients.get(clientId);
|
|
37
|
+
|
|
38
|
+
return client ? { ...client } : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async getAllClients(): Promise<WsClientData[]> {
|
|
42
|
+
return Array.from(this.clients.values()).map((c) => ({ ...c }));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async updateClient(clientId: string, data: Partial<WsClientData>): Promise<void> {
|
|
46
|
+
const client = this.clients.get(clientId);
|
|
47
|
+
if (client) {
|
|
48
|
+
this.clients.set(clientId, { ...client, ...data });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async getClientCount(): Promise<number> {
|
|
53
|
+
return this.clients.size;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ============================================================================
|
|
57
|
+
// Room Operations
|
|
58
|
+
// ============================================================================
|
|
59
|
+
|
|
60
|
+
async createRoom(room: WsRoom): Promise<void> {
|
|
61
|
+
this.rooms.set(room.name, { ...room, clientIds: [...room.clientIds] });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async deleteRoom(name: string): Promise<void> {
|
|
65
|
+
const room = this.rooms.get(name);
|
|
66
|
+
if (room) {
|
|
67
|
+
// Remove room from all clients' room lists
|
|
68
|
+
for (const clientId of room.clientIds) {
|
|
69
|
+
const client = this.clients.get(clientId);
|
|
70
|
+
if (client) {
|
|
71
|
+
client.rooms = client.rooms.filter((r) => r !== name);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
this.rooms.delete(name);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async getRoom(name: string): Promise<WsRoom | null> {
|
|
79
|
+
const room = this.rooms.get(name);
|
|
80
|
+
|
|
81
|
+
return room ? { ...room, clientIds: [...room.clientIds] } : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async getAllRooms(): Promise<WsRoom[]> {
|
|
85
|
+
return Array.from(this.rooms.values()).map((r) => ({
|
|
86
|
+
...r,
|
|
87
|
+
clientIds: [...r.clientIds],
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async getRoomsByPattern(pattern: string): Promise<WsRoom[]> {
|
|
92
|
+
const matchingRooms: WsRoom[] = [];
|
|
93
|
+
|
|
94
|
+
for (const [name, room] of this.rooms) {
|
|
95
|
+
if (isPatternMatch(pattern, name)) {
|
|
96
|
+
matchingRooms.push({ ...room, clientIds: [...room.clientIds] });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return matchingRooms;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async updateRoomMetadata(name: string, metadata: Record<string, unknown>): Promise<void> {
|
|
104
|
+
const room = this.rooms.get(name);
|
|
105
|
+
if (room) {
|
|
106
|
+
room.metadata = { ...room.metadata, ...metadata };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ============================================================================
|
|
111
|
+
// Room Membership Operations
|
|
112
|
+
// ============================================================================
|
|
113
|
+
|
|
114
|
+
async addClientToRoom(clientId: string, roomName: string): Promise<void> {
|
|
115
|
+
// Get or create room
|
|
116
|
+
let room = this.rooms.get(roomName);
|
|
117
|
+
if (!room) {
|
|
118
|
+
room = { name: roomName, clientIds: [] };
|
|
119
|
+
this.rooms.set(roomName, room);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Add client to room if not already there
|
|
123
|
+
if (!room.clientIds.includes(clientId)) {
|
|
124
|
+
room.clientIds.push(clientId);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Update client's room list
|
|
128
|
+
const client = this.clients.get(clientId);
|
|
129
|
+
if (client && !client.rooms.includes(roomName)) {
|
|
130
|
+
client.rooms.push(roomName);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async removeClientFromRoom(clientId: string, roomName: string): Promise<void> {
|
|
135
|
+
// Remove from room
|
|
136
|
+
const room = this.rooms.get(roomName);
|
|
137
|
+
if (room) {
|
|
138
|
+
room.clientIds = room.clientIds.filter((id) => id !== clientId);
|
|
139
|
+
|
|
140
|
+
// Delete room if empty
|
|
141
|
+
if (room.clientIds.length === 0) {
|
|
142
|
+
this.rooms.delete(roomName);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Update client's room list
|
|
147
|
+
const client = this.clients.get(clientId);
|
|
148
|
+
if (client) {
|
|
149
|
+
client.rooms = client.rooms.filter((r) => r !== roomName);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async getClientsInRoom(roomName: string): Promise<string[]> {
|
|
154
|
+
const room = this.rooms.get(roomName);
|
|
155
|
+
|
|
156
|
+
return room ? [...room.clientIds] : [];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async getRoomsForClient(clientId: string): Promise<string[]> {
|
|
160
|
+
const client = this.clients.get(clientId);
|
|
161
|
+
|
|
162
|
+
return client ? [...client.rooms] : [];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async removeClientFromAllRooms(clientId: string): Promise<void> {
|
|
166
|
+
const client = this.clients.get(clientId);
|
|
167
|
+
if (!client) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Remove from all rooms
|
|
172
|
+
for (const roomName of client.rooms) {
|
|
173
|
+
const room = this.rooms.get(roomName);
|
|
174
|
+
if (room) {
|
|
175
|
+
room.clientIds = room.clientIds.filter((id) => id !== clientId);
|
|
176
|
+
|
|
177
|
+
// Delete room if empty
|
|
178
|
+
if (room.clientIds.length === 0) {
|
|
179
|
+
this.rooms.delete(roomName);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Clear client's room list
|
|
185
|
+
client.rooms = [];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ============================================================================
|
|
189
|
+
// Lifecycle Operations
|
|
190
|
+
// ============================================================================
|
|
191
|
+
|
|
192
|
+
async clear(): Promise<void> {
|
|
193
|
+
this.clients.clear();
|
|
194
|
+
this.rooms.clear();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async close(): Promise<void> {
|
|
198
|
+
// No-op for in-memory storage
|
|
199
|
+
await this.clear();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ============================================================================
|
|
203
|
+
// Debug/Stats Methods
|
|
204
|
+
// ============================================================================
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Get storage statistics (for debugging)
|
|
208
|
+
*/
|
|
209
|
+
getStats(): { clientCount: number; roomCount: number } {
|
|
210
|
+
return {
|
|
211
|
+
clientCount: this.clients.size,
|
|
212
|
+
roomCount: this.rooms.size,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Create a new in-memory WebSocket storage instance
|
|
219
|
+
*/
|
|
220
|
+
export function createInMemoryWsStorage(): WsStorageAdapter {
|
|
221
|
+
return new InMemoryWsStorage();
|
|
222
|
+
}
|