@africode/core 5.0.0
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/AFRICODE_FRAMEWORK_GUIDE.md +707 -0
- package/LICENSE +623 -0
- package/README.md +442 -0
- package/bin/africode.js +73 -0
- package/bin/africode.js.1758507140 +343 -0
- package/bin/cli.ts +83 -0
- package/bin/create-africode.js +158 -0
- package/bin/scaffold.ts +219 -0
- package/components/accordion.js +183 -0
- package/components/alert.js +131 -0
- package/components/auth.js +172 -0
- package/components/avatar.js +117 -0
- package/components/badge.js +104 -0
- package/components/base.d.ts +139 -0
- package/components/base.js +184 -0
- package/components/button.js +164 -0
- package/components/card.js +137 -0
- package/components/cultural-card.js +243 -0
- package/components/divider.js +83 -0
- package/components/dropdown.js +171 -0
- package/components/error-boundary.js +155 -0
- package/components/form.js +131 -0
- package/components/grid.js +273 -0
- package/components/hero.js +138 -0
- package/components/icon.js +36 -0
- package/components/index.js +57 -0
- package/components/input.js +256 -0
- package/components/kanga-card.js +185 -0
- package/components/language-switcher.js +108 -0
- package/components/loader.js +80 -0
- package/components/modal.js +262 -0
- package/components/motion.js +84 -0
- package/components/navbar.js +236 -0
- package/components/pattern-showcase.js +225 -0
- package/components/progress.js +134 -0
- package/components/react.js +111 -0
- package/components/section.js +54 -0
- package/components/select.js +322 -0
- package/components/sidebar.js +180 -0
- package/components/skeleton.js +85 -0
- package/components/table.js +181 -0
- package/components/tabs.js +202 -0
- package/components/theme-toggle.js +82 -0
- package/components/toast.js +139 -0
- package/components/tooltip.js +167 -0
- package/core/a2ui-schema-manager.js +344 -0
- package/core/a2ui.js +431 -0
- package/core/bun-runtime.js +799 -0
- package/core/cli/commands/add.js +23 -0
- package/core/cli/commands/audit.js +58 -0
- package/core/cli/commands/build.js +137 -0
- package/core/cli/commands/create-plugin.js +241 -0
- package/core/cli/commands/dev.js +228 -0
- package/core/cli/commands/lint.js +23 -0
- package/core/cli/commands/test.js +34 -0
- package/core/cli/migrator.js +71 -0
- package/core/cli/ui.js +46 -0
- package/core/compliance.js +628 -0
- package/core/config.js +263 -0
- package/core/db-advanced.js +481 -0
- package/core/db.js +284 -0
- package/core/enhanced-hmr.js +404 -0
- package/core/errors.js +222 -0
- package/core/file-router.js +290 -0
- package/core/heartbeat.js +64 -0
- package/core/hmr-client.js +204 -0
- package/core/hmr.js +196 -0
- package/core/html.d.ts +116 -0
- package/core/html.js +160 -0
- package/core/hydration.js +52 -0
- package/core/lipa-namba-journey.js +572 -0
- package/core/motion.js +106 -0
- package/core/nida-cig-middleware.js +455 -0
- package/core/patterns.d.ts +124 -0
- package/core/patterns.js +833 -0
- package/core/plugins/index.js +312 -0
- package/core/router.js +387 -0
- package/core/sdk-client.js +62 -0
- package/core/sdk.d.ts +133 -0
- package/core/sdk.js +123 -0
- package/core/seo.js +76 -0
- package/core/server/auth-endpoints.js +339 -0
- package/core/server/auth.js +180 -0
- package/core/server/csrf.js +206 -0
- package/core/server/db.js +39 -0
- package/core/server/middleware.js +324 -0
- package/core/server/rate-limit.js +238 -0
- package/core/server/render.js +69 -0
- package/core/server/router.js +120 -0
- package/core/shim.js +28 -0
- package/core/state.d.ts +86 -0
- package/core/state.js +242 -0
- package/core/store.d.ts +122 -0
- package/core/store.js +61 -0
- package/core/validation.d.ts +233 -0
- package/core/validation.js +590 -0
- package/core/websocket.js +639 -0
- package/dist/africode.js +2905 -0
- package/dist/africode.js.map +61 -0
- package/dist/build-info.json +23 -0
- package/dist/components.js +2888 -0
- package/dist/components.js.map +58 -0
- package/dist/styles/africanity.css +322 -0
- package/dist/styles/typography.css +141 -0
- package/docs/IDE-Guide.md +50 -0
- package/package.json +110 -0
- package/src/index.ts +196 -0
- package/styles/africanity.css +322 -0
- package/styles/typography.css +141 -0
- package/templates/starter/.env.example +15 -0
- package/templates/starter/africode.config.js +40 -0
- package/templates/starter/package.json +14 -0
- package/templates/starter/src/pages/index.html +46 -0
- package/templates/starter/src/pages/index.js +32 -0
- package/templates/starter/src/styles/main.css +4 -0
- package/templates/starter-3d/.env.example +7 -0
- package/templates/starter-3d/africode.config.js +29 -0
- package/templates/starter-3d/components/af-model-viewer.js +125 -0
- package/templates/starter-3d/package.json +15 -0
- package/templates/starter-3d/src/pages/index.html +46 -0
- package/templates/starter-3d/src/pages/index.js +50 -0
- package/templates/starter-3d/src/styles/main.css +4 -0
- package/templates/starter-react/.env.example +15 -0
- package/templates/starter-react/africode.config.js +40 -0
- package/templates/starter-react/package.json +16 -0
- package/templates/starter-react/src/pages/index.html +46 -0
- package/templates/starter-react/src/pages/index.js +68 -0
- package/templates/starter-react/src/styles/main.css +4 -0
- package/templates/starter-tailwind/.env.example +15 -0
- package/templates/starter-tailwind/africode.config.js +40 -0
- package/templates/starter-tailwind/package.json +20 -0
- package/templates/starter-tailwind/src/pages/index.html +46 -0
- package/templates/starter-tailwind/src/pages/index.js +37 -0
- package/templates/starter-tailwind/src/styles/main.css +4 -0
- package/templates/starter-tailwind/src/styles/tailwind.css +1 -0
- package/templates/starter-tailwind/src/tailwind-loader.js +30 -0
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AfriCode WebSocket Support
|
|
3
|
+
*
|
|
4
|
+
* Real-time bidirectional communication with:
|
|
5
|
+
* - Persistent WebSocket connections
|
|
6
|
+
* - Room/channel management
|
|
7
|
+
* - Event broadcasting
|
|
8
|
+
* - Message queueing
|
|
9
|
+
* - Presence tracking
|
|
10
|
+
* - Automatic reconnection
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* WebSocket Server Manager
|
|
15
|
+
* Handles connections, rooms, and broadcasting
|
|
16
|
+
*/
|
|
17
|
+
export class WebSocketServer {
|
|
18
|
+
constructor() {
|
|
19
|
+
this.connections = new Map(); // connection_id -> WebSocket
|
|
20
|
+
this.rooms = new Map(); // room_name -> Set of connection_ids
|
|
21
|
+
this.messageHandlers = new Map(); // event_type -> handler function
|
|
22
|
+
this.userConnections = new Map(); // user_id -> Set of connection_ids
|
|
23
|
+
this.messageQueue = []; // For offline message queueing
|
|
24
|
+
this.maxQueueSize = 1000;
|
|
25
|
+
this.connectionCounter = 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Register a message handler for a specific event type
|
|
30
|
+
*/
|
|
31
|
+
on(eventType, handler) {
|
|
32
|
+
if (!this.messageHandlers.has(eventType)) {
|
|
33
|
+
this.messageHandlers.set(eventType, []);
|
|
34
|
+
}
|
|
35
|
+
this.messageHandlers.get(eventType).push(handler);
|
|
36
|
+
return this;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Handle new WebSocket connection
|
|
41
|
+
*/
|
|
42
|
+
handleConnection(ws, userId = null) {
|
|
43
|
+
const connectionId = `conn_${++this.connectionCounter}`;
|
|
44
|
+
this.connections.set(connectionId, {
|
|
45
|
+
ws,
|
|
46
|
+
userId,
|
|
47
|
+
connectedAt: Date.now(),
|
|
48
|
+
lastHeartbeat: Date.now(),
|
|
49
|
+
rooms: new Set()
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (userId) {
|
|
53
|
+
if (!this.userConnections.has(userId)) {
|
|
54
|
+
this.userConnections.set(userId, new Set());
|
|
55
|
+
}
|
|
56
|
+
this.userConnections.get(userId).add(connectionId);
|
|
57
|
+
this.broadcast('user:online', { userId, connectionId }, null, 'presence');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Setup message handler
|
|
61
|
+
ws.onmessage = (event) => this.handleMessage(connectionId, event);
|
|
62
|
+
|
|
63
|
+
// Setup close handler
|
|
64
|
+
ws.onclose = () => this.handleDisconnection(connectionId);
|
|
65
|
+
|
|
66
|
+
// Send welcome message
|
|
67
|
+
this.send(connectionId, 'connection:established', {
|
|
68
|
+
connectionId,
|
|
69
|
+
userId,
|
|
70
|
+
serverTime: Date.now()
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Start heartbeat
|
|
74
|
+
this.startHeartbeat(connectionId);
|
|
75
|
+
|
|
76
|
+
return connectionId;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Handle incoming messages
|
|
81
|
+
*/
|
|
82
|
+
handleMessage(connectionId, event) {
|
|
83
|
+
try {
|
|
84
|
+
const data = JSON.parse(event.data);
|
|
85
|
+
const { type, payload, roomId } = data;
|
|
86
|
+
|
|
87
|
+
const connInfo = this.connections.get(connectionId);
|
|
88
|
+
if (!connInfo) return;
|
|
89
|
+
|
|
90
|
+
connInfo.lastHeartbeat = Date.now();
|
|
91
|
+
|
|
92
|
+
// Handle special system messages
|
|
93
|
+
if (type === 'ping') {
|
|
94
|
+
this.send(connectionId, 'pong', { timestamp: Date.now() });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (type === 'join:room') {
|
|
99
|
+
this.joinRoom(connectionId, payload.roomId, payload.userId);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (type === 'leave:room') {
|
|
104
|
+
this.leaveRoom(connectionId, payload.roomId);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Call registered handlers
|
|
109
|
+
const handlers = this.messageHandlers.get(type) || [];
|
|
110
|
+
for (const handler of handlers) {
|
|
111
|
+
try {
|
|
112
|
+
handler({
|
|
113
|
+
connectionId,
|
|
114
|
+
userId: connInfo.userId,
|
|
115
|
+
payload,
|
|
116
|
+
roomId,
|
|
117
|
+
timestamp: Date.now()
|
|
118
|
+
});
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.error(`Error in handler for ${type}:`, error);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.error('Error handling message:', error);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Handle disconnection
|
|
130
|
+
*/
|
|
131
|
+
handleDisconnection(connectionId) {
|
|
132
|
+
const connInfo = this.connections.get(connectionId);
|
|
133
|
+
if (!connInfo) return;
|
|
134
|
+
|
|
135
|
+
const { userId } = connInfo;
|
|
136
|
+
|
|
137
|
+
// Remove from all rooms
|
|
138
|
+
for (const roomId of connInfo.rooms) {
|
|
139
|
+
this.leaveRoom(connectionId, roomId);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Remove from user connections
|
|
143
|
+
if (userId) {
|
|
144
|
+
const userConns = this.userConnections.get(userId);
|
|
145
|
+
if (userConns) {
|
|
146
|
+
userConns.delete(connectionId);
|
|
147
|
+
if (userConns.size === 0) {
|
|
148
|
+
this.userConnections.delete(userId);
|
|
149
|
+
this.broadcast('user:offline', { userId }, null, 'presence');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
this.connections.delete(connectionId);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Join a room
|
|
159
|
+
*/
|
|
160
|
+
joinRoom(connectionId, roomId, userId = null) {
|
|
161
|
+
if (!this.rooms.has(roomId)) {
|
|
162
|
+
this.rooms.set(roomId, new Set());
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const room = this.rooms.get(roomId);
|
|
166
|
+
room.add(connectionId);
|
|
167
|
+
|
|
168
|
+
const connInfo = this.connections.get(connectionId);
|
|
169
|
+
if (connInfo) {
|
|
170
|
+
connInfo.rooms.add(roomId);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Broadcast user joined to room
|
|
174
|
+
this.broadcast(
|
|
175
|
+
'room:user:joined',
|
|
176
|
+
{ userId, connectionId, roomId },
|
|
177
|
+
roomId,
|
|
178
|
+
'room'
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// Send room members list to new member
|
|
182
|
+
const members = Array.from(room).map(cid => {
|
|
183
|
+
const ci = this.connections.get(cid);
|
|
184
|
+
return { connectionId: cid, userId: ci?.userId };
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
this.send(connectionId, 'room:members', { roomId, members });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Leave a room
|
|
192
|
+
*/
|
|
193
|
+
leaveRoom(connectionId, roomId) {
|
|
194
|
+
const room = this.rooms.get(roomId);
|
|
195
|
+
if (!room) return;
|
|
196
|
+
|
|
197
|
+
room.delete(connectionId);
|
|
198
|
+
|
|
199
|
+
const connInfo = this.connections.get(connectionId);
|
|
200
|
+
if (connInfo) {
|
|
201
|
+
connInfo.rooms.delete(roomId);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Broadcast user left to room
|
|
205
|
+
this.broadcast(
|
|
206
|
+
'room:user:left',
|
|
207
|
+
{ connectionId, roomId },
|
|
208
|
+
roomId,
|
|
209
|
+
'room'
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
// Clean up empty rooms
|
|
213
|
+
if (room.size === 0) {
|
|
214
|
+
this.rooms.delete(roomId);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Send message to specific connection
|
|
220
|
+
*/
|
|
221
|
+
send(connectionId, type, payload) {
|
|
222
|
+
const connInfo = this.connections.get(connectionId);
|
|
223
|
+
if (!connInfo) return false;
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
connInfo.ws.send(JSON.stringify({
|
|
227
|
+
type,
|
|
228
|
+
payload,
|
|
229
|
+
timestamp: Date.now()
|
|
230
|
+
}));
|
|
231
|
+
return true;
|
|
232
|
+
} catch (error) {
|
|
233
|
+
console.error(`Error sending to ${connectionId}:`, error);
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Broadcast message to all connections in a room or globally
|
|
240
|
+
*/
|
|
241
|
+
broadcast(type, payload, roomId = null, scope = 'global') {
|
|
242
|
+
const timestamp = Date.now();
|
|
243
|
+
|
|
244
|
+
if (scope === 'room' && roomId) {
|
|
245
|
+
const room = this.rooms.get(roomId);
|
|
246
|
+
if (room) {
|
|
247
|
+
for (const connectionId of room) {
|
|
248
|
+
const connInfo = this.connections.get(connectionId);
|
|
249
|
+
if (connInfo) {
|
|
250
|
+
try {
|
|
251
|
+
connInfo.ws.send(JSON.stringify({
|
|
252
|
+
type,
|
|
253
|
+
payload,
|
|
254
|
+
roomId,
|
|
255
|
+
timestamp
|
|
256
|
+
}));
|
|
257
|
+
} catch (error) {
|
|
258
|
+
console.error(`Error broadcasting to ${connectionId}:`, error);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
} else if (scope === 'user' && payload.userId) {
|
|
264
|
+
const userConns = this.userConnections.get(payload.userId);
|
|
265
|
+
if (userConns) {
|
|
266
|
+
for (const connectionId of userConns) {
|
|
267
|
+
this.send(connectionId, type, payload);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
} else if (scope === 'presence') {
|
|
271
|
+
// Broadcast presence to all connections
|
|
272
|
+
for (const [connId, connInfo] of this.connections.entries()) {
|
|
273
|
+
try {
|
|
274
|
+
connInfo.ws.send(JSON.stringify({
|
|
275
|
+
type,
|
|
276
|
+
payload,
|
|
277
|
+
timestamp
|
|
278
|
+
}));
|
|
279
|
+
} catch (error) {
|
|
280
|
+
console.error(`Error broadcasting presence to ${connId}:`, error);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
// Global broadcast
|
|
285
|
+
for (const [connId, connInfo] of this.connections.entries()) {
|
|
286
|
+
try {
|
|
287
|
+
connInfo.ws.send(JSON.stringify({
|
|
288
|
+
type,
|
|
289
|
+
payload,
|
|
290
|
+
timestamp
|
|
291
|
+
}));
|
|
292
|
+
} catch (error) {
|
|
293
|
+
console.error(`Error broadcasting to ${connId}:`, error);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Send message to all users in a room
|
|
301
|
+
*/
|
|
302
|
+
toRoom(roomId, type, payload, excludeConnectionId = null) {
|
|
303
|
+
const room = this.rooms.get(roomId);
|
|
304
|
+
if (!room) return;
|
|
305
|
+
|
|
306
|
+
for (const connectionId of room) {
|
|
307
|
+
if (excludeConnectionId === connectionId) continue;
|
|
308
|
+
this.send(connectionId, type, payload);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Send message to specific user (all their connections)
|
|
314
|
+
*/
|
|
315
|
+
toUser(userId, type, payload) {
|
|
316
|
+
const userConns = this.userConnections.get(userId);
|
|
317
|
+
if (!userConns) return;
|
|
318
|
+
|
|
319
|
+
for (const connectionId of userConns) {
|
|
320
|
+
this.send(connectionId, type, payload);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Queue message for offline user
|
|
326
|
+
*/
|
|
327
|
+
queueMessage(userId, type, payload) {
|
|
328
|
+
if (this.messageQueue.length >= this.maxQueueSize) {
|
|
329
|
+
this.messageQueue.shift(); // Remove oldest
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
this.messageQueue.push({
|
|
333
|
+
userId,
|
|
334
|
+
type,
|
|
335
|
+
payload,
|
|
336
|
+
queuedAt: Date.now()
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Deliver queued messages to user
|
|
342
|
+
*/
|
|
343
|
+
deliverQueuedMessages(userId) {
|
|
344
|
+
const queued = this.messageQueue.filter(msg => msg.userId === userId);
|
|
345
|
+
|
|
346
|
+
for (const msg of queued) {
|
|
347
|
+
this.toUser(userId, msg.type, msg.payload);
|
|
348
|
+
this.messageQueue = this.messageQueue.filter(m => m !== msg);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return queued.length;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Start heartbeat for connection
|
|
356
|
+
*/
|
|
357
|
+
startHeartbeat(connectionId) {
|
|
358
|
+
const interval = setInterval(() => {
|
|
359
|
+
const connInfo = this.connections.get(connectionId);
|
|
360
|
+
if (!connInfo) {
|
|
361
|
+
clearInterval(interval);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const timeSinceLastHeartbeat = Date.now() - connInfo.lastHeartbeat;
|
|
366
|
+
if (timeSinceLastHeartbeat > 60000) {
|
|
367
|
+
// No heartbeat for 60 seconds, consider dead
|
|
368
|
+
try {
|
|
369
|
+
connInfo.ws.close(1000, 'Heartbeat timeout');
|
|
370
|
+
} catch (e) {
|
|
371
|
+
// Already closed
|
|
372
|
+
}
|
|
373
|
+
clearInterval(interval);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
this.send(connectionId, 'ping', { timestamp: Date.now() });
|
|
378
|
+
}, 30000); // Every 30 seconds
|
|
379
|
+
|
|
380
|
+
return interval;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Get room information
|
|
385
|
+
*/
|
|
386
|
+
getRoomInfo(roomId) {
|
|
387
|
+
const room = this.rooms.get(roomId);
|
|
388
|
+
if (!room) return null;
|
|
389
|
+
|
|
390
|
+
const members = Array.from(room).map(connId => {
|
|
391
|
+
const connInfo = this.connections.get(connId);
|
|
392
|
+
return {
|
|
393
|
+
connectionId: connId,
|
|
394
|
+
userId: connInfo?.userId,
|
|
395
|
+
connectedAt: connInfo?.connectedAt
|
|
396
|
+
};
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
roomId,
|
|
401
|
+
memberCount: room.size,
|
|
402
|
+
members,
|
|
403
|
+
createdAt: Math.min(...members.map(m => m.connectedAt))
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Get user presence information
|
|
409
|
+
*/
|
|
410
|
+
getUserPresence(userId) {
|
|
411
|
+
const userConns = this.userConnections.get(userId);
|
|
412
|
+
if (!userConns) return null;
|
|
413
|
+
|
|
414
|
+
const connections = Array.from(userConns).map(connId => {
|
|
415
|
+
const connInfo = this.connections.get(connId);
|
|
416
|
+
return {
|
|
417
|
+
connectionId: connId,
|
|
418
|
+
connectedAt: connInfo?.connectedAt,
|
|
419
|
+
rooms: Array.from(connInfo?.rooms || [])
|
|
420
|
+
};
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
userId,
|
|
425
|
+
connectionCount: userConns.size,
|
|
426
|
+
connections,
|
|
427
|
+
isOnline: userConns.size > 0
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Get all active rooms
|
|
433
|
+
*/
|
|
434
|
+
getAllRooms() {
|
|
435
|
+
const roomList = [];
|
|
436
|
+
|
|
437
|
+
for (const [roomId, members] of this.rooms.entries()) {
|
|
438
|
+
roomList.push({
|
|
439
|
+
roomId,
|
|
440
|
+
memberCount: members.size
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return roomList;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Get connection statistics
|
|
449
|
+
*/
|
|
450
|
+
getStats() {
|
|
451
|
+
return {
|
|
452
|
+
activeConnections: this.connections.size,
|
|
453
|
+
activeRooms: this.rooms.size,
|
|
454
|
+
activeUsers: this.userConnections.size,
|
|
455
|
+
queuedMessages: this.messageQueue.length,
|
|
456
|
+
messageHandlers: this.messageHandlers.size
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Close all connections gracefully
|
|
462
|
+
*/
|
|
463
|
+
shutdown() {
|
|
464
|
+
for (const [connId, connInfo] of this.connections.entries()) {
|
|
465
|
+
try {
|
|
466
|
+
connInfo.ws.close(1001, 'Server shutting down');
|
|
467
|
+
} catch (e) {
|
|
468
|
+
// Already closed
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
this.connections.clear();
|
|
472
|
+
this.rooms.clear();
|
|
473
|
+
this.userConnections.clear();
|
|
474
|
+
this.messageHandlers.clear();
|
|
475
|
+
this.messageQueue = [];
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* WebSocket Client Helper
|
|
481
|
+
* For use in Bun HTTP server context
|
|
482
|
+
*/
|
|
483
|
+
export class WebSocketClient {
|
|
484
|
+
constructor(url, userId = null) {
|
|
485
|
+
this.url = url;
|
|
486
|
+
this.userId = userId;
|
|
487
|
+
this.ws = null;
|
|
488
|
+
this.reconnectAttempts = 0;
|
|
489
|
+
this.maxReconnectAttempts = 10;
|
|
490
|
+
this.reconnectDelay = 1000; // Start at 1 second
|
|
491
|
+
this.messageHandlers = new Map();
|
|
492
|
+
this.roomSubscriptions = new Set();
|
|
493
|
+
this.isConnecting = false;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Connect to WebSocket server
|
|
498
|
+
*/
|
|
499
|
+
connect() {
|
|
500
|
+
if (this.isConnecting || this.ws) return;
|
|
501
|
+
this.isConnecting = true;
|
|
502
|
+
|
|
503
|
+
try {
|
|
504
|
+
this.ws = new WebSocket(this.url);
|
|
505
|
+
|
|
506
|
+
this.ws.onopen = () => {
|
|
507
|
+
console.log('Connected to WebSocket server');
|
|
508
|
+
this.reconnectAttempts = 0;
|
|
509
|
+
this.reconnectDelay = 1000;
|
|
510
|
+
this.emit('connected', { userId: this.userId });
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
this.ws.onmessage = (event) => {
|
|
514
|
+
const data = JSON.parse(event.data);
|
|
515
|
+
this.emit(data.type, data.payload);
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
this.ws.onerror = (error) => {
|
|
519
|
+
console.error('WebSocket error:', error);
|
|
520
|
+
this.emit('error', error);
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
this.ws.onclose = () => {
|
|
524
|
+
console.log('Disconnected from WebSocket');
|
|
525
|
+
this.ws = null;
|
|
526
|
+
this.isConnecting = false;
|
|
527
|
+
this.attemptReconnect();
|
|
528
|
+
};
|
|
529
|
+
} catch (error) {
|
|
530
|
+
console.error('Failed to connect to WebSocket:', error);
|
|
531
|
+
this.isConnecting = false;
|
|
532
|
+
this.attemptReconnect();
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Attempt to reconnect with exponential backoff
|
|
538
|
+
*/
|
|
539
|
+
attemptReconnect() {
|
|
540
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
541
|
+
this.emit('reconnect:failed', {
|
|
542
|
+
attempts: this.reconnectAttempts
|
|
543
|
+
});
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
this.reconnectAttempts++;
|
|
548
|
+
const delay = Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), 30000);
|
|
549
|
+
|
|
550
|
+
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
|
551
|
+
|
|
552
|
+
setTimeout(() => {
|
|
553
|
+
this.connect();
|
|
554
|
+
}, delay);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Register message handler
|
|
559
|
+
*/
|
|
560
|
+
on(type, handler) {
|
|
561
|
+
if (!this.messageHandlers.has(type)) {
|
|
562
|
+
this.messageHandlers.set(type, []);
|
|
563
|
+
}
|
|
564
|
+
this.messageHandlers.get(type).push(handler);
|
|
565
|
+
return this;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Emit event to handlers
|
|
570
|
+
*/
|
|
571
|
+
emit(type, payload) {
|
|
572
|
+
const handlers = this.messageHandlers.get(type) || [];
|
|
573
|
+
for (const handler of handlers) {
|
|
574
|
+
try {
|
|
575
|
+
handler(payload);
|
|
576
|
+
} catch (error) {
|
|
577
|
+
console.error(`Error in handler for ${type}:`, error);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Send message to server
|
|
584
|
+
*/
|
|
585
|
+
send(type, payload) {
|
|
586
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
587
|
+
console.warn('WebSocket not connected');
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
try {
|
|
592
|
+
this.ws.send(JSON.stringify({
|
|
593
|
+
type,
|
|
594
|
+
payload,
|
|
595
|
+
timestamp: Date.now()
|
|
596
|
+
}));
|
|
597
|
+
return true;
|
|
598
|
+
} catch (error) {
|
|
599
|
+
console.error('Error sending message:', error);
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Join a room
|
|
606
|
+
*/
|
|
607
|
+
joinRoom(roomId) {
|
|
608
|
+
this.roomSubscriptions.add(roomId);
|
|
609
|
+
return this.send('join:room', {
|
|
610
|
+
roomId,
|
|
611
|
+
userId: this.userId
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Leave a room
|
|
617
|
+
*/
|
|
618
|
+
leaveRoom(roomId) {
|
|
619
|
+
this.roomSubscriptions.delete(roomId);
|
|
620
|
+
return this.send('leave:room', {
|
|
621
|
+
roomId
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Disconnect from server
|
|
627
|
+
*/
|
|
628
|
+
disconnect() {
|
|
629
|
+
if (this.ws) {
|
|
630
|
+
this.ws.close(1000, 'Client closing');
|
|
631
|
+
this.ws = null;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export default {
|
|
637
|
+
WebSocketServer,
|
|
638
|
+
WebSocketClient
|
|
639
|
+
};
|