servcraft 0.1.0 → 0.1.3
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/.claude/settings.local.json +30 -0
- package/.github/CODEOWNERS +18 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +46 -0
- package/.github/dependabot.yml +59 -0
- package/.github/workflows/ci.yml +188 -0
- package/.github/workflows/release.yml +195 -0
- package/AUDIT.md +602 -0
- package/LICENSE +21 -0
- package/README.md +1102 -1
- package/dist/cli/index.cjs +2026 -2168
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +2026 -2168
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +595 -616
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +114 -52
- package/dist/index.d.ts +114 -52
- package/dist/index.js +595 -616
- package/dist/index.js.map +1 -1
- package/docs/CLI-001_MULTI_DB_PLAN.md +546 -0
- package/docs/DATABASE_MULTI_ORM.md +399 -0
- package/docs/PHASE1_BREAKDOWN.md +346 -0
- package/docs/PROGRESS.md +550 -0
- package/docs/modules/ANALYTICS.md +226 -0
- package/docs/modules/API-VERSIONING.md +252 -0
- package/docs/modules/AUDIT.md +192 -0
- package/docs/modules/AUTH.md +431 -0
- package/docs/modules/CACHE.md +346 -0
- package/docs/modules/EMAIL.md +254 -0
- package/docs/modules/FEATURE-FLAG.md +291 -0
- package/docs/modules/I18N.md +294 -0
- package/docs/modules/MEDIA-PROCESSING.md +281 -0
- package/docs/modules/MFA.md +266 -0
- package/docs/modules/NOTIFICATION.md +311 -0
- package/docs/modules/OAUTH.md +237 -0
- package/docs/modules/PAYMENT.md +804 -0
- package/docs/modules/QUEUE.md +540 -0
- package/docs/modules/RATE-LIMIT.md +339 -0
- package/docs/modules/SEARCH.md +288 -0
- package/docs/modules/SECURITY.md +327 -0
- package/docs/modules/SESSION.md +382 -0
- package/docs/modules/SWAGGER.md +305 -0
- package/docs/modules/UPLOAD.md +296 -0
- package/docs/modules/USER.md +505 -0
- package/docs/modules/VALIDATION.md +294 -0
- package/docs/modules/WEBHOOK.md +270 -0
- package/docs/modules/WEBSOCKET.md +691 -0
- package/package.json +53 -38
- package/prisma/schema.prisma +395 -1
- package/src/cli/commands/add-module.ts +520 -87
- package/src/cli/commands/db.ts +3 -4
- package/src/cli/commands/docs.ts +256 -6
- package/src/cli/commands/generate.ts +12 -19
- package/src/cli/commands/init.ts +384 -214
- package/src/cli/index.ts +0 -4
- package/src/cli/templates/repository.ts +6 -1
- package/src/cli/templates/routes.ts +6 -21
- package/src/cli/utils/docs-generator.ts +6 -7
- package/src/cli/utils/env-manager.ts +717 -0
- package/src/cli/utils/field-parser.ts +16 -7
- package/src/cli/utils/interactive-prompt.ts +223 -0
- package/src/cli/utils/template-manager.ts +346 -0
- package/src/config/database.config.ts +183 -0
- package/src/config/env.ts +0 -10
- package/src/config/index.ts +0 -14
- package/src/core/server.ts +1 -1
- package/src/database/adapters/mongoose.adapter.ts +132 -0
- package/src/database/adapters/prisma.adapter.ts +118 -0
- package/src/database/connection.ts +190 -0
- package/src/database/interfaces/database.interface.ts +85 -0
- package/src/database/interfaces/index.ts +7 -0
- package/src/database/interfaces/repository.interface.ts +129 -0
- package/src/database/models/mongoose/index.ts +7 -0
- package/src/database/models/mongoose/payment.schema.ts +347 -0
- package/src/database/models/mongoose/user.schema.ts +154 -0
- package/src/database/prisma.ts +1 -4
- package/src/database/redis.ts +101 -0
- package/src/database/repositories/mongoose/index.ts +7 -0
- package/src/database/repositories/mongoose/payment.repository.ts +380 -0
- package/src/database/repositories/mongoose/user.repository.ts +255 -0
- package/src/database/seed.ts +6 -1
- package/src/index.ts +9 -20
- package/src/middleware/security.ts +2 -6
- package/src/modules/analytics/analytics.routes.ts +80 -0
- package/src/modules/analytics/analytics.service.ts +364 -0
- package/src/modules/analytics/index.ts +18 -0
- package/src/modules/analytics/types.ts +180 -0
- package/src/modules/api-versioning/index.ts +15 -0
- package/src/modules/api-versioning/types.ts +86 -0
- package/src/modules/api-versioning/versioning.middleware.ts +120 -0
- package/src/modules/api-versioning/versioning.routes.ts +54 -0
- package/src/modules/api-versioning/versioning.service.ts +189 -0
- package/src/modules/audit/audit.repository.ts +206 -0
- package/src/modules/audit/audit.service.ts +27 -59
- package/src/modules/auth/auth.controller.ts +2 -2
- package/src/modules/auth/auth.middleware.ts +3 -9
- package/src/modules/auth/auth.routes.ts +10 -107
- package/src/modules/auth/auth.service.ts +126 -23
- package/src/modules/auth/index.ts +3 -4
- package/src/modules/cache/cache.service.ts +367 -0
- package/src/modules/cache/index.ts +10 -0
- package/src/modules/cache/types.ts +44 -0
- package/src/modules/email/email.service.ts +3 -10
- package/src/modules/email/templates.ts +2 -8
- package/src/modules/feature-flag/feature-flag.repository.ts +303 -0
- package/src/modules/feature-flag/feature-flag.routes.ts +247 -0
- package/src/modules/feature-flag/feature-flag.service.ts +566 -0
- package/src/modules/feature-flag/index.ts +20 -0
- package/src/modules/feature-flag/types.ts +192 -0
- package/src/modules/i18n/i18n.middleware.ts +186 -0
- package/src/modules/i18n/i18n.routes.ts +191 -0
- package/src/modules/i18n/i18n.service.ts +456 -0
- package/src/modules/i18n/index.ts +18 -0
- package/src/modules/i18n/types.ts +118 -0
- package/src/modules/media-processing/index.ts +17 -0
- package/src/modules/media-processing/media-processing.routes.ts +111 -0
- package/src/modules/media-processing/media-processing.service.ts +245 -0
- package/src/modules/media-processing/types.ts +156 -0
- package/src/modules/mfa/index.ts +20 -0
- package/src/modules/mfa/mfa.repository.ts +206 -0
- package/src/modules/mfa/mfa.routes.ts +595 -0
- package/src/modules/mfa/mfa.service.ts +572 -0
- package/src/modules/mfa/totp.ts +150 -0
- package/src/modules/mfa/types.ts +57 -0
- package/src/modules/notification/index.ts +20 -0
- package/src/modules/notification/notification.repository.ts +356 -0
- package/src/modules/notification/notification.service.ts +483 -0
- package/src/modules/notification/types.ts +119 -0
- package/src/modules/oauth/index.ts +20 -0
- package/src/modules/oauth/oauth.repository.ts +219 -0
- package/src/modules/oauth/oauth.routes.ts +446 -0
- package/src/modules/oauth/oauth.service.ts +293 -0
- package/src/modules/oauth/providers/apple.provider.ts +250 -0
- package/src/modules/oauth/providers/facebook.provider.ts +181 -0
- package/src/modules/oauth/providers/github.provider.ts +248 -0
- package/src/modules/oauth/providers/google.provider.ts +189 -0
- package/src/modules/oauth/providers/twitter.provider.ts +214 -0
- package/src/modules/oauth/types.ts +94 -0
- package/src/modules/payment/index.ts +19 -0
- package/src/modules/payment/payment.repository.ts +733 -0
- package/src/modules/payment/payment.routes.ts +390 -0
- package/src/modules/payment/payment.service.ts +354 -0
- package/src/modules/payment/providers/mobile-money.provider.ts +274 -0
- package/src/modules/payment/providers/paypal.provider.ts +190 -0
- package/src/modules/payment/providers/stripe.provider.ts +215 -0
- package/src/modules/payment/types.ts +140 -0
- package/src/modules/queue/cron.ts +438 -0
- package/src/modules/queue/index.ts +87 -0
- package/src/modules/queue/queue.routes.ts +600 -0
- package/src/modules/queue/queue.service.ts +842 -0
- package/src/modules/queue/types.ts +222 -0
- package/src/modules/queue/workers.ts +366 -0
- package/src/modules/rate-limit/index.ts +59 -0
- package/src/modules/rate-limit/rate-limit.middleware.ts +134 -0
- package/src/modules/rate-limit/rate-limit.routes.ts +269 -0
- package/src/modules/rate-limit/rate-limit.service.ts +348 -0
- package/src/modules/rate-limit/stores/memory.store.ts +165 -0
- package/src/modules/rate-limit/stores/redis.store.ts +322 -0
- package/src/modules/rate-limit/types.ts +153 -0
- package/src/modules/search/adapters/elasticsearch.adapter.ts +326 -0
- package/src/modules/search/adapters/meilisearch.adapter.ts +261 -0
- package/src/modules/search/adapters/memory.adapter.ts +278 -0
- package/src/modules/search/index.ts +21 -0
- package/src/modules/search/search.service.ts +234 -0
- package/src/modules/search/types.ts +214 -0
- package/src/modules/security/index.ts +40 -0
- package/src/modules/security/sanitize.ts +223 -0
- package/src/modules/security/security-audit.service.ts +388 -0
- package/src/modules/security/security.middleware.ts +398 -0
- package/src/modules/session/index.ts +3 -0
- package/src/modules/session/session.repository.ts +159 -0
- package/src/modules/session/session.service.ts +340 -0
- package/src/modules/session/types.ts +38 -0
- package/src/modules/swagger/index.ts +7 -1
- package/src/modules/swagger/schema-builder.ts +16 -4
- package/src/modules/swagger/swagger.service.ts +9 -10
- package/src/modules/swagger/types.ts +0 -2
- package/src/modules/upload/index.ts +14 -0
- package/src/modules/upload/types.ts +83 -0
- package/src/modules/upload/upload.repository.ts +199 -0
- package/src/modules/upload/upload.routes.ts +311 -0
- package/src/modules/upload/upload.service.ts +448 -0
- package/src/modules/user/index.ts +3 -3
- package/src/modules/user/user.controller.ts +15 -9
- package/src/modules/user/user.repository.ts +237 -113
- package/src/modules/user/user.routes.ts +39 -164
- package/src/modules/user/user.service.ts +4 -3
- package/src/modules/validation/validator.ts +12 -17
- package/src/modules/webhook/index.ts +91 -0
- package/src/modules/webhook/retry.ts +196 -0
- package/src/modules/webhook/signature.ts +135 -0
- package/src/modules/webhook/types.ts +181 -0
- package/src/modules/webhook/webhook.repository.ts +358 -0
- package/src/modules/webhook/webhook.routes.ts +442 -0
- package/src/modules/webhook/webhook.service.ts +457 -0
- package/src/modules/websocket/features.ts +504 -0
- package/src/modules/websocket/index.ts +106 -0
- package/src/modules/websocket/middlewares.ts +298 -0
- package/src/modules/websocket/types.ts +181 -0
- package/src/modules/websocket/websocket.service.ts +692 -0
- package/src/utils/errors.ts +7 -0
- package/src/utils/pagination.ts +4 -1
- package/tests/helpers/db-check.ts +79 -0
- package/tests/integration/auth-redis.test.ts +94 -0
- package/tests/integration/cache-redis.test.ts +387 -0
- package/tests/integration/mongoose-repositories.test.ts +410 -0
- package/tests/integration/payment-prisma.test.ts +637 -0
- package/tests/integration/queue-bullmq.test.ts +417 -0
- package/tests/integration/user-prisma.test.ts +441 -0
- package/tests/integration/websocket-socketio.test.ts +552 -0
- package/tests/setup.ts +11 -9
- package/vitest.config.ts +3 -8
- package/npm-cache/_cacache/content-v2/sha512/1c/d0/03440d500a0487621aad1d6402978340698976602046db8e24fa03c01ee6c022c69b0582f969042d9442ee876ac35c038e960dd427d1e622fa24b8eb7dba +0 -0
- package/npm-cache/_cacache/content-v2/sha512/42/55/28b493ca491833e5aab0e9c3108d29ab3f36c248ca88f45d4630674fce9130959e56ae308797ac2b6328fa7f09a610b9550ed09cb971d039876d293fc69d +0 -0
- package/npm-cache/_cacache/content-v2/sha512/e0/12/f360dc9315ee5f17844a0c8c233ee6bf7c30837c4a02ea0d56c61c7f7ab21c0e958e50ed2c57c59f983c762b93056778c9009b2398ffc26def0183999b13 +0 -0
- package/npm-cache/_cacache/content-v2/sha512/ed/b0/fae1161902898f4c913c67d7f6cdf6be0665aec3b389b9c4f4f0a101ca1da59badf1b59c4e0030f5223023b8d63cfe501c46a32c20c895d4fb3f11ca2232 +0 -0
- package/npm-cache/_cacache/index-v5/58/94/c2cba79e0f16b4c10e95a87e32255741149e8222cc314a476aab67c39cc0 +0 -5
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
import { Server } from 'socket.io';
|
|
4
|
+
import type { Server as HTTPServer } from 'http';
|
|
5
|
+
import { createAdapter } from '@socket.io/redis-adapter';
|
|
6
|
+
import { Redis } from 'ioredis';
|
|
7
|
+
import { logger } from '../../core/logger.js';
|
|
8
|
+
import type {
|
|
9
|
+
WebSocketConfig,
|
|
10
|
+
SocketUser,
|
|
11
|
+
Room,
|
|
12
|
+
Message,
|
|
13
|
+
BroadcastOptions,
|
|
14
|
+
ConnectionStats,
|
|
15
|
+
RoomStats,
|
|
16
|
+
AuthenticatedSocket,
|
|
17
|
+
} from './types.js';
|
|
18
|
+
|
|
19
|
+
// In-memory storage (replace with Redis in production)
|
|
20
|
+
const connectedUsers = new Map<string, SocketUser>();
|
|
21
|
+
const rooms = new Map<string, Room>();
|
|
22
|
+
const messages = new Map<string, Message[]>();
|
|
23
|
+
const userSockets = new Map<string, Set<string>>();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* WebSocket Service
|
|
27
|
+
* Manages real-time connections with Socket.io
|
|
28
|
+
*
|
|
29
|
+
* Now using real Socket.io with Redis adapter for multi-instance support.
|
|
30
|
+
*/
|
|
31
|
+
export class WebSocketService extends EventEmitter {
|
|
32
|
+
private config: WebSocketConfig;
|
|
33
|
+
private io: Server | null = null;
|
|
34
|
+
private pubClient: Redis | null = null;
|
|
35
|
+
private subClient: Redis | null = null;
|
|
36
|
+
private stats: ConnectionStats = {
|
|
37
|
+
totalConnections: 0,
|
|
38
|
+
activeConnections: 0,
|
|
39
|
+
totalRooms: 0,
|
|
40
|
+
messagesPerMinute: 0,
|
|
41
|
+
bytesPerMinute: 0,
|
|
42
|
+
avgLatency: 0,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
constructor(config?: WebSocketConfig) {
|
|
46
|
+
super();
|
|
47
|
+
this.config = {
|
|
48
|
+
path: '/socket.io',
|
|
49
|
+
pingTimeout: 60000,
|
|
50
|
+
pingInterval: 25000,
|
|
51
|
+
maxHttpBufferSize: 1e6,
|
|
52
|
+
...config,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
logger.info('WebSocket service initialized');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Initialize Socket.io server
|
|
60
|
+
* Pass an HTTP server instance to enable WebSocket support
|
|
61
|
+
*/
|
|
62
|
+
initialize(httpServer?: HTTPServer): void {
|
|
63
|
+
if (!httpServer) {
|
|
64
|
+
logger.warn('No HTTP server provided - WebSocket service running in mock mode');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
// Create Socket.io server
|
|
70
|
+
this.io = new Server(httpServer, {
|
|
71
|
+
path: this.config.path,
|
|
72
|
+
pingTimeout: this.config.pingTimeout,
|
|
73
|
+
pingInterval: this.config.pingInterval,
|
|
74
|
+
maxHttpBufferSize: this.config.maxHttpBufferSize,
|
|
75
|
+
cors: this.config.cors || {
|
|
76
|
+
origin: '*',
|
|
77
|
+
credentials: true,
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Setup Redis adapter if configured
|
|
82
|
+
if (this.config.redis) {
|
|
83
|
+
this.setupRedisAdapter();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Setup connection handlers
|
|
87
|
+
this.setupConnectionHandlers();
|
|
88
|
+
|
|
89
|
+
logger.info({ path: this.config.path }, 'Socket.io server initialized');
|
|
90
|
+
} catch (error) {
|
|
91
|
+
logger.error({ err: error }, 'Failed to initialize Socket.io server');
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Setup Redis adapter for multi-instance support
|
|
98
|
+
*/
|
|
99
|
+
private setupRedisAdapter(): void {
|
|
100
|
+
if (!this.config.redis || !this.io) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const redisConfig = this.config.redis;
|
|
106
|
+
|
|
107
|
+
// Create pub/sub Redis clients
|
|
108
|
+
this.pubClient = new Redis({
|
|
109
|
+
host: redisConfig.host,
|
|
110
|
+
port: redisConfig.port,
|
|
111
|
+
password: redisConfig.password,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
this.subClient = new Redis({
|
|
115
|
+
host: redisConfig.host,
|
|
116
|
+
port: redisConfig.port,
|
|
117
|
+
password: redisConfig.password,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Setup event handlers
|
|
121
|
+
this.pubClient.on('connect', () => {
|
|
122
|
+
logger.info('Socket.io Redis pub client connected');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
this.pubClient.on('error', (error: Error) => {
|
|
126
|
+
logger.error({ err: error }, 'Socket.io Redis pub client error');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
this.subClient.on('connect', () => {
|
|
130
|
+
logger.info('Socket.io Redis sub client connected');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
this.subClient.on('error', (error: Error) => {
|
|
134
|
+
logger.error({ err: error }, 'Socket.io Redis sub client error');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Attach Redis adapter to Socket.io
|
|
138
|
+
this.io.adapter(createAdapter(this.pubClient, this.subClient));
|
|
139
|
+
|
|
140
|
+
logger.info(
|
|
141
|
+
{ host: redisConfig.host, port: redisConfig.port },
|
|
142
|
+
'Socket.io Redis adapter configured'
|
|
143
|
+
);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
logger.error({ err: error }, 'Failed to setup Redis adapter');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Setup Socket.io connection handlers
|
|
151
|
+
*/
|
|
152
|
+
private setupConnectionHandlers(): void {
|
|
153
|
+
if (!this.io) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
this.io.on('connection', (socket) => {
|
|
158
|
+
const authenticatedSocket = socket as unknown as AuthenticatedSocket;
|
|
159
|
+
|
|
160
|
+
// Handle connection
|
|
161
|
+
this.handleConnection(authenticatedSocket).catch((error) => {
|
|
162
|
+
logger.error({ err: error, socketId: socket.id }, 'Error handling connection');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Handle disconnection
|
|
166
|
+
socket.on('disconnect', (reason) => {
|
|
167
|
+
this.handleDisconnection(socket.id, reason).catch((error) => {
|
|
168
|
+
logger.error({ err: error, socketId: socket.id }, 'Error handling disconnection');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Handle room join
|
|
173
|
+
socket.on('room:join', async (data: { roomId: string }) => {
|
|
174
|
+
try {
|
|
175
|
+
await this.joinRoom(socket.id, data.roomId);
|
|
176
|
+
socket.join(data.roomId);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
logger.error({ err: error, socketId: socket.id }, 'Error joining room');
|
|
179
|
+
socket.emit('error', { message: 'Failed to join room' });
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Handle room leave
|
|
184
|
+
socket.on('room:leave', async (data: { roomId: string }) => {
|
|
185
|
+
try {
|
|
186
|
+
await this.leaveRoom(socket.id, data.roomId);
|
|
187
|
+
socket.leave(data.roomId);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
logger.error({ err: error, socketId: socket.id }, 'Error leaving room');
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Handle message
|
|
194
|
+
socket.on(
|
|
195
|
+
'message',
|
|
196
|
+
async (data: {
|
|
197
|
+
roomId: string;
|
|
198
|
+
content: string;
|
|
199
|
+
type?: Message['type'];
|
|
200
|
+
metadata?: Record<string, unknown>;
|
|
201
|
+
}) => {
|
|
202
|
+
try {
|
|
203
|
+
const user = this.getUser(socket.id);
|
|
204
|
+
if (!user) {
|
|
205
|
+
socket.emit('error', { message: 'User not found' });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const message = await this.sendMessage(
|
|
210
|
+
data.roomId,
|
|
211
|
+
user.id,
|
|
212
|
+
data.content,
|
|
213
|
+
data.type,
|
|
214
|
+
data.metadata
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// Broadcast to room
|
|
218
|
+
this.io?.to(data.roomId).emit('message', message);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
logger.error({ err: error, socketId: socket.id }, 'Error sending message');
|
|
221
|
+
socket.emit('error', { message: 'Failed to send message' });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// Handle typing indicator
|
|
227
|
+
socket.on('typing', (data: { roomId: string; isTyping: boolean }) => {
|
|
228
|
+
const user = this.getUser(socket.id);
|
|
229
|
+
if (user) {
|
|
230
|
+
socket.to(data.roomId).emit('typing', {
|
|
231
|
+
userId: user.id,
|
|
232
|
+
username: user.username,
|
|
233
|
+
roomId: data.roomId,
|
|
234
|
+
isTyping: data.isTyping,
|
|
235
|
+
timestamp: new Date(),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
logger.info('Socket.io connection handlers configured');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Handle user connection
|
|
246
|
+
*/
|
|
247
|
+
async handleConnection(socket: AuthenticatedSocket): Promise<void> {
|
|
248
|
+
const userId = socket.userId || socket.id;
|
|
249
|
+
|
|
250
|
+
const user: SocketUser = {
|
|
251
|
+
id: userId,
|
|
252
|
+
socketId: socket.id,
|
|
253
|
+
username: socket.handshake.query.username as string,
|
|
254
|
+
email: socket.handshake.query.email as string,
|
|
255
|
+
connectedAt: new Date(),
|
|
256
|
+
lastActivity: new Date(),
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
connectedUsers.set(socket.id, user);
|
|
260
|
+
|
|
261
|
+
// Track user sockets
|
|
262
|
+
if (!userSockets.has(userId)) {
|
|
263
|
+
userSockets.set(userId, new Set());
|
|
264
|
+
}
|
|
265
|
+
userSockets.get(userId)!.add(socket.id);
|
|
266
|
+
|
|
267
|
+
this.stats.totalConnections++;
|
|
268
|
+
this.stats.activeConnections++;
|
|
269
|
+
|
|
270
|
+
this.emit('connection', user);
|
|
271
|
+
|
|
272
|
+
logger.info({ userId, socketId: socket.id, username: user.username }, 'User connected');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Handle user disconnection
|
|
277
|
+
*/
|
|
278
|
+
async handleDisconnection(socketId: string, reason?: string): Promise<void> {
|
|
279
|
+
const user = connectedUsers.get(socketId);
|
|
280
|
+
|
|
281
|
+
if (user) {
|
|
282
|
+
connectedUsers.delete(socketId);
|
|
283
|
+
|
|
284
|
+
// Remove from user sockets
|
|
285
|
+
const sockets = userSockets.get(user.id);
|
|
286
|
+
if (sockets) {
|
|
287
|
+
sockets.delete(socketId);
|
|
288
|
+
if (sockets.size === 0) {
|
|
289
|
+
userSockets.delete(user.id);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
this.stats.activeConnections--;
|
|
294
|
+
|
|
295
|
+
this.emit('disconnect', user, reason);
|
|
296
|
+
|
|
297
|
+
logger.info(
|
|
298
|
+
{ userId: user.id, socketId, username: user.username, reason },
|
|
299
|
+
'User disconnected'
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Get connected user
|
|
306
|
+
*/
|
|
307
|
+
getUser(socketId: string): SocketUser | undefined {
|
|
308
|
+
return connectedUsers.get(socketId);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Get all connected users
|
|
313
|
+
*/
|
|
314
|
+
getConnectedUsers(): SocketUser[] {
|
|
315
|
+
return Array.from(connectedUsers.values());
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Get user by ID (may have multiple sockets)
|
|
320
|
+
*/
|
|
321
|
+
getUserSockets(userId: string): string[] {
|
|
322
|
+
return Array.from(userSockets.get(userId) || []);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Check if user is online
|
|
327
|
+
*/
|
|
328
|
+
isUserOnline(userId: string): boolean {
|
|
329
|
+
return userSockets.has(userId) && userSockets.get(userId)!.size > 0;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Create a room
|
|
334
|
+
*/
|
|
335
|
+
async createRoom(
|
|
336
|
+
name: string,
|
|
337
|
+
namespace = 'default',
|
|
338
|
+
createdBy?: string,
|
|
339
|
+
metadata?: Record<string, unknown>
|
|
340
|
+
): Promise<Room> {
|
|
341
|
+
const room: Room = {
|
|
342
|
+
id: randomUUID(),
|
|
343
|
+
name,
|
|
344
|
+
namespace,
|
|
345
|
+
members: new Set(),
|
|
346
|
+
metadata,
|
|
347
|
+
createdAt: new Date(),
|
|
348
|
+
createdBy,
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
rooms.set(room.id, room);
|
|
352
|
+
messages.set(room.id, []);
|
|
353
|
+
this.stats.totalRooms++;
|
|
354
|
+
|
|
355
|
+
logger.info({ roomId: room.id, name, namespace }, 'Room created');
|
|
356
|
+
|
|
357
|
+
return room;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Get room
|
|
362
|
+
*/
|
|
363
|
+
getRoom(roomId: string): Room | undefined {
|
|
364
|
+
return rooms.get(roomId);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* List rooms
|
|
369
|
+
*/
|
|
370
|
+
listRooms(namespace?: string): Room[] {
|
|
371
|
+
const allRooms = Array.from(rooms.values());
|
|
372
|
+
|
|
373
|
+
if (namespace) {
|
|
374
|
+
return allRooms.filter((room) => room.namespace === namespace);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return allRooms;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Join room
|
|
382
|
+
*/
|
|
383
|
+
async joinRoom(socketId: string, roomId: string): Promise<void> {
|
|
384
|
+
const user = connectedUsers.get(socketId);
|
|
385
|
+
const room = rooms.get(roomId);
|
|
386
|
+
|
|
387
|
+
if (!user || !room) {
|
|
388
|
+
throw new Error('User or room not found');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
room.members.add(socketId);
|
|
392
|
+
|
|
393
|
+
this.emit('room:join', { user, room });
|
|
394
|
+
|
|
395
|
+
logger.info({ userId: user.id, socketId, roomId, roomName: room.name }, 'User joined room');
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Leave room
|
|
400
|
+
*/
|
|
401
|
+
async leaveRoom(socketId: string, roomId: string): Promise<void> {
|
|
402
|
+
const user = connectedUsers.get(socketId);
|
|
403
|
+
const room = rooms.get(roomId);
|
|
404
|
+
|
|
405
|
+
if (!user || !room) {
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
room.members.delete(socketId);
|
|
410
|
+
|
|
411
|
+
this.emit('room:leave', { user, room });
|
|
412
|
+
|
|
413
|
+
logger.info({ userId: user.id, socketId, roomId, roomName: room.name }, 'User left room');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Delete room
|
|
418
|
+
*/
|
|
419
|
+
async deleteRoom(roomId: string): Promise<void> {
|
|
420
|
+
const room = rooms.get(roomId);
|
|
421
|
+
|
|
422
|
+
if (room) {
|
|
423
|
+
rooms.delete(roomId);
|
|
424
|
+
messages.delete(roomId);
|
|
425
|
+
this.stats.totalRooms--;
|
|
426
|
+
|
|
427
|
+
logger.info({ roomId, roomName: room.name }, 'Room deleted');
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Get room members
|
|
433
|
+
*/
|
|
434
|
+
getRoomMembers(roomId: string): SocketUser[] {
|
|
435
|
+
const room = rooms.get(roomId);
|
|
436
|
+
|
|
437
|
+
if (!room) {
|
|
438
|
+
return [];
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return Array.from(room.members)
|
|
442
|
+
.map((socketId) => connectedUsers.get(socketId))
|
|
443
|
+
.filter((user): user is SocketUser => user !== undefined);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Send message to room
|
|
448
|
+
*/
|
|
449
|
+
async sendMessage(
|
|
450
|
+
roomId: string,
|
|
451
|
+
userId: string,
|
|
452
|
+
content: string,
|
|
453
|
+
type: Message['type'] = 'text',
|
|
454
|
+
metadata?: Record<string, unknown>
|
|
455
|
+
): Promise<Message> {
|
|
456
|
+
const room = rooms.get(roomId);
|
|
457
|
+
|
|
458
|
+
if (!room) {
|
|
459
|
+
throw new Error('Room not found');
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const user = Array.from(connectedUsers.values()).find((u) => u.id === userId);
|
|
463
|
+
|
|
464
|
+
const message: Message = {
|
|
465
|
+
id: randomUUID(),
|
|
466
|
+
roomId,
|
|
467
|
+
userId,
|
|
468
|
+
username: user?.username,
|
|
469
|
+
content,
|
|
470
|
+
type,
|
|
471
|
+
metadata,
|
|
472
|
+
timestamp: new Date(),
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const roomMessages = messages.get(roomId) || [];
|
|
476
|
+
roomMessages.push(message);
|
|
477
|
+
messages.set(roomId, roomMessages);
|
|
478
|
+
|
|
479
|
+
this.emit('message', message);
|
|
480
|
+
|
|
481
|
+
logger.debug({ messageId: message.id, roomId, userId }, 'Message sent');
|
|
482
|
+
|
|
483
|
+
return message;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Get room messages
|
|
488
|
+
*/
|
|
489
|
+
getRoomMessages(roomId: string, limit = 50, offset = 0): Message[] {
|
|
490
|
+
const roomMessages = messages.get(roomId) || [];
|
|
491
|
+
|
|
492
|
+
return roomMessages.slice(-limit - offset, offset > 0 ? -offset : undefined).reverse();
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Broadcast event to room
|
|
497
|
+
*/
|
|
498
|
+
async broadcastToRoom(
|
|
499
|
+
roomId: string,
|
|
500
|
+
event: string,
|
|
501
|
+
data: unknown,
|
|
502
|
+
options?: BroadcastOptions
|
|
503
|
+
): Promise<void> {
|
|
504
|
+
const room = rooms.get(roomId);
|
|
505
|
+
|
|
506
|
+
if (!room) {
|
|
507
|
+
throw new Error('Room not found');
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (this.io) {
|
|
511
|
+
// Use Socket.io room broadcasting
|
|
512
|
+
const except = options?.except || [];
|
|
513
|
+
if (except.length > 0) {
|
|
514
|
+
// Emit to room except specific sockets
|
|
515
|
+
for (const socketId of room.members) {
|
|
516
|
+
if (!except.includes(socketId)) {
|
|
517
|
+
this.io.to(socketId).emit(event, data);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
} else {
|
|
521
|
+
// Emit to entire room
|
|
522
|
+
this.io.to(roomId).emit(event, data);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
logger.debug({ event, roomId, memberCount: room.members.size }, 'Broadcast to room');
|
|
526
|
+
} else {
|
|
527
|
+
logger.debug({ event, roomId }, 'WebSocket not initialized - skipping broadcast');
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Broadcast to specific users
|
|
533
|
+
*/
|
|
534
|
+
async broadcastToUsers(userIds: string[], event: string, data: unknown): Promise<void> {
|
|
535
|
+
if (this.io) {
|
|
536
|
+
for (const userId of userIds) {
|
|
537
|
+
const socketIds = userSockets.get(userId);
|
|
538
|
+
|
|
539
|
+
if (socketIds) {
|
|
540
|
+
for (const socketId of socketIds) {
|
|
541
|
+
this.io.to(socketId).emit(event, data);
|
|
542
|
+
logger.debug({ socketId, userId, event }, 'Broadcasting to user');
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
logger.debug({ event, userCount: userIds.length }, 'Broadcast to users');
|
|
548
|
+
} else {
|
|
549
|
+
logger.debug(
|
|
550
|
+
{ event, userCount: userIds.length },
|
|
551
|
+
'WebSocket not initialized - skipping broadcast'
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Broadcast to all connected users
|
|
558
|
+
*/
|
|
559
|
+
async broadcastToAll(event: string, data: unknown, options?: BroadcastOptions): Promise<void> {
|
|
560
|
+
if (this.io) {
|
|
561
|
+
const except = options?.except || [];
|
|
562
|
+
|
|
563
|
+
if (except.length > 0) {
|
|
564
|
+
// Emit to all except specific sockets
|
|
565
|
+
for (const socketId of connectedUsers.keys()) {
|
|
566
|
+
if (!except.includes(socketId)) {
|
|
567
|
+
this.io.to(socketId).emit(event, data);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
} else {
|
|
571
|
+
// Emit to all connected sockets
|
|
572
|
+
this.io.emit(event, data);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
logger.debug({ event, totalUsers: connectedUsers.size }, 'Broadcast to all users');
|
|
576
|
+
} else {
|
|
577
|
+
logger.debug({ event }, 'WebSocket not initialized - skipping broadcast');
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Emit event to specific socket
|
|
583
|
+
*/
|
|
584
|
+
async emitToSocket(socketId: string, event: string, data: unknown): Promise<void> {
|
|
585
|
+
if (this.io) {
|
|
586
|
+
this.io.to(socketId).emit(event, data);
|
|
587
|
+
logger.debug({ socketId, event }, 'Emit to socket');
|
|
588
|
+
} else {
|
|
589
|
+
logger.debug({ socketId, event }, 'WebSocket not initialized - skipping emit');
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Get connection statistics
|
|
595
|
+
*/
|
|
596
|
+
getStats(): ConnectionStats {
|
|
597
|
+
return { ...this.stats };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Get room statistics
|
|
602
|
+
*/
|
|
603
|
+
getRoomStats(roomId: string): RoomStats | null {
|
|
604
|
+
const room = rooms.get(roomId);
|
|
605
|
+
const roomMessages = messages.get(roomId);
|
|
606
|
+
|
|
607
|
+
if (!room) {
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const lastMessage = roomMessages?.[roomMessages.length - 1];
|
|
612
|
+
|
|
613
|
+
return {
|
|
614
|
+
roomId: room.id,
|
|
615
|
+
memberCount: room.members.size,
|
|
616
|
+
messageCount: roomMessages?.length || 0,
|
|
617
|
+
createdAt: room.createdAt,
|
|
618
|
+
lastActivity: lastMessage?.timestamp || room.createdAt,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Disconnect user
|
|
624
|
+
*/
|
|
625
|
+
async disconnectUser(userId: string, reason?: string): Promise<void> {
|
|
626
|
+
const socketIds = userSockets.get(userId);
|
|
627
|
+
|
|
628
|
+
if (socketIds && this.io) {
|
|
629
|
+
for (const socketId of socketIds) {
|
|
630
|
+
const socket = this.io.sockets.sockets.get(socketId);
|
|
631
|
+
if (socket) {
|
|
632
|
+
socket.disconnect(true);
|
|
633
|
+
}
|
|
634
|
+
await this.handleDisconnection(socketId, reason);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
logger.info({ userId, reason }, 'User forcefully disconnected');
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Cleanup inactive connections
|
|
643
|
+
*/
|
|
644
|
+
async cleanup(inactiveMinutes = 30): Promise<number> {
|
|
645
|
+
const cutoff = new Date(Date.now() - inactiveMinutes * 60 * 1000);
|
|
646
|
+
let cleaned = 0;
|
|
647
|
+
|
|
648
|
+
for (const [socketId, user] of connectedUsers.entries()) {
|
|
649
|
+
if (user.lastActivity < cutoff) {
|
|
650
|
+
await this.handleDisconnection(socketId, 'inactive');
|
|
651
|
+
cleaned++;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
logger.info({ cleaned, inactiveMinutes }, 'Cleaned up inactive connections');
|
|
656
|
+
|
|
657
|
+
return cleaned;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Shutdown service
|
|
662
|
+
*/
|
|
663
|
+
async shutdown(): Promise<void> {
|
|
664
|
+
// Disconnect all users
|
|
665
|
+
for (const socketId of connectedUsers.keys()) {
|
|
666
|
+
await this.handleDisconnection(socketId, 'server_shutdown');
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Close Redis clients
|
|
670
|
+
if (this.pubClient) {
|
|
671
|
+
await this.pubClient.quit();
|
|
672
|
+
this.pubClient = null;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (this.subClient) {
|
|
676
|
+
await this.subClient.quit();
|
|
677
|
+
this.subClient = null;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Close Socket.io server
|
|
681
|
+
if (this.io) {
|
|
682
|
+
await new Promise<void>((resolve) => {
|
|
683
|
+
this.io?.close(() => {
|
|
684
|
+
resolve();
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
this.io = null;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
logger.info('WebSocket service shut down');
|
|
691
|
+
}
|
|
692
|
+
}
|