wexa-chat 0.1.23 → 0.1.32
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 +67 -225
- package/dist/client/index.d.ts +6 -10
- package/dist/client/index.js +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/index.d.cts +18 -64
- package/dist/index.d.ts +18 -64
- package/dist/index.js +101 -316
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +96 -310
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
# wexa-chat (Chat Core)
|
|
2
2
|
|
|
3
|
-
TypeScript chat core for MongoDB with optional Redis and a built‑in WebSocket transport. Ships server APIs and a lightweight browser client bundle
|
|
3
|
+
TypeScript chat core for MongoDB with optional Redis and a built‑in WebSocket transport. Ships server APIs and a lightweight browser client bundle.
|
|
4
4
|
|
|
5
5
|
- Server: `import { initChat } from 'wexa-chat'`
|
|
6
6
|
- Client: `import { SocketProvider, useSocket, getSocketManager, type ChatEvent } from 'wexa-chat/client'`
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
## Key Features
|
|
9
|
+
|
|
10
|
+
- Single socket connection per user ID - no need to join individual conversations
|
|
11
|
+
- Direct communication between users via unique identifiers
|
|
12
|
+
- Messages are stored with conversationId for organization and retrieval
|
|
13
|
+
- Support for various ID types (userId, organizationId, candidateId, applicationId)
|
|
14
|
+
- Simplified Redis pub/sub for cross-instance communication
|
|
9
15
|
|
|
10
16
|
## Installation
|
|
11
17
|
|
|
12
18
|
```bash
|
|
13
19
|
npm install wexa-chat mongoose ioredis
|
|
20
|
+
# or with pnpm
|
|
21
|
+
pnpm add wexa-chat mongoose ioredis
|
|
14
22
|
```
|
|
15
23
|
|
|
16
24
|
## Server usage
|
|
@@ -29,15 +37,13 @@ export async function getChatInstance(httpServer?: HTTPServer) {
|
|
|
29
37
|
// Attach WS transport when server becomes available
|
|
30
38
|
if (httpServer && !(chatInstance.transport as any).socket) {
|
|
31
39
|
chatInstance = await initChat(mongoose, {
|
|
32
|
-
participantModels: ['User'],
|
|
33
|
-
memberRoles: ['member', 'admin'],
|
|
34
40
|
mongoUri: process.env.MONGODB_URI!,
|
|
35
41
|
// Optional Redis for cross‑instance fanout
|
|
36
42
|
redis: process.env.REDIS_URL || process.env.REDIS_HOST
|
|
37
43
|
? { url: process.env.REDIS_URL, host: process.env.REDIS_HOST, port: Number(process.env.REDIS_PORT || 6379) }
|
|
38
44
|
: undefined,
|
|
39
|
-
// IMPORTANT: must match the client
|
|
40
|
-
socket: { path: '/
|
|
45
|
+
// IMPORTANT: must match the client endpoint
|
|
46
|
+
socket: { path: '/api/socket' },
|
|
41
47
|
enableTextSearch: true,
|
|
42
48
|
}, httpServer);
|
|
43
49
|
}
|
|
@@ -46,13 +52,11 @@ export async function getChatInstance(httpServer?: HTTPServer) {
|
|
|
46
52
|
|
|
47
53
|
// First‑time init; only pass socket when server exists
|
|
48
54
|
chatInstance = await initChat(mongoose, {
|
|
49
|
-
participantModels: ['User'],
|
|
50
|
-
memberRoles: ['member', 'admin'],
|
|
51
55
|
mongoUri: process.env.MONGODB_URI!,
|
|
52
56
|
redis: process.env.REDIS_URL || process.env.REDIS_HOST
|
|
53
57
|
? { url: process.env.REDIS_URL, host: process.env.REDIS_HOST, port: Number(process.env.REDIS_PORT || 6379) }
|
|
54
58
|
: undefined,
|
|
55
|
-
socket: httpServer ? { path: '/
|
|
59
|
+
socket: httpServer ? { path: '/api/socket' } : undefined,
|
|
56
60
|
enableTextSearch: true,
|
|
57
61
|
}, httpServer);
|
|
58
62
|
|
|
@@ -63,8 +67,8 @@ export async function getChatInstance(httpServer?: HTTPServer) {
|
|
|
63
67
|
### WebSocket transport
|
|
64
68
|
|
|
65
69
|
- Transport uses native `ws` and attaches an HTTP upgrade listener when `httpServer` is provided.
|
|
66
|
-
- Set `socket.path` to the URL path for upgrades. The client bundle expects `/
|
|
67
|
-
- Authentication: client connects to `ws(s)://host/
|
|
70
|
+
- Set `socket.path` to the URL path for upgrades. The client bundle expects `/api/socket`.
|
|
71
|
+
- Authentication: client connects to `ws(s)://host/api/socket?userId=...`. The server validates the handshake and associates the user.
|
|
68
72
|
|
|
69
73
|
### Next.js warm‑up route
|
|
70
74
|
|
|
@@ -85,7 +89,7 @@ export default async function handler(_req: NextApiRequest, res: NextApiResponse
|
|
|
85
89
|
}
|
|
86
90
|
```
|
|
87
91
|
|
|
88
|
-
The browser client performs a one
|
|
92
|
+
The browser client performs a one-time GET of `/api/socket` before opening the WebSocket.
|
|
89
93
|
|
|
90
94
|
## Options overview (see `src/types/init.ts`)
|
|
91
95
|
|
|
@@ -96,8 +100,6 @@ export type SocketConfig = {
|
|
|
96
100
|
};
|
|
97
101
|
|
|
98
102
|
export type InitOptions = {
|
|
99
|
-
participantModels: string[]; // e.g. ['User']
|
|
100
|
-
memberRoles: string[]; // e.g. ['member', 'admin']
|
|
101
103
|
mongoUri: string; // MongoDB connection
|
|
102
104
|
enableTextSearch?: boolean;
|
|
103
105
|
redis?: { // optional
|
|
@@ -105,7 +107,6 @@ export type InitOptions = {
|
|
|
105
107
|
socketConnectTimeout?: number; keepAlive?: number;
|
|
106
108
|
};
|
|
107
109
|
socket?: SocketConfig; // WebSocket config
|
|
108
|
-
presence?: { enabled?: boolean; heartbeatSec?: number };
|
|
109
110
|
rateLimit?: { sendPerMin?: number; typingPerMin?: number };
|
|
110
111
|
}
|
|
111
112
|
```
|
|
@@ -121,127 +122,28 @@ export type InitOptions = {
|
|
|
121
122
|
- `searchByParticipantPair({ organizationId, a, b })`
|
|
122
123
|
|
|
123
124
|
- Messages
|
|
124
|
-
- `sendMessage({ organizationId, conversationId, senderModel, senderId, text, kind?, parentMessageId?, rootThreadId? })`
|
|
125
|
+
- `sendMessage({ organizationId, conversationId, senderModel, senderId, text, targetUserId, kind?, parentMessageId?, rootThreadId? })`
|
|
125
126
|
- `listMessages({ organizationId, conversationId, limit?, cursor? })`
|
|
126
127
|
- `listMessagesWithSenders({ organizationId, conversationId, limit?, cursor?, populateSenders: true, populateOptions? })`
|
|
127
|
-
- `markRead({ organizationId, conversationId, participantModel, participantId, messageId })`
|
|
128
128
|
|
|
129
129
|
DTO types live in `src/types/dto.ts`.
|
|
130
130
|
|
|
131
|
-
###
|
|
131
|
+
### Sending Messages with Direct Routing
|
|
132
132
|
|
|
133
|
-
The `
|
|
133
|
+
The `sendMessage` method now supports direct routing to specific users with the new `targetUserId` parameter:
|
|
134
134
|
|
|
135
135
|
```ts
|
|
136
|
-
//
|
|
137
|
-
|
|
136
|
+
// Send a message to a specific user
|
|
137
|
+
await chat.services.messages.sendMessage({
|
|
138
138
|
organizationId: 'org-123',
|
|
139
139
|
conversationId: 'conv-456',
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
//
|
|
144
|
-
const messagesWithCustomSenders = await chat.services.messages.listMessagesWithSenders({
|
|
145
|
-
organizationId: 'org-123',
|
|
146
|
-
conversationId: 'conv-456',
|
|
147
|
-
populateSenders: true,
|
|
148
|
-
populateOptions: {
|
|
149
|
-
fields: '_id name email profilePicture', // Fields to select from sender model
|
|
150
|
-
modelMapping: {
|
|
151
|
-
'User': 'CustomUserModel', // If your senderModel doesn't match the actual Mongoose model name
|
|
152
|
-
'Bot': 'BotModel'
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
// Accessing populated data
|
|
158
|
-
messagesWithSenders.items.forEach(message => {
|
|
159
|
-
console.log(`Message from ${message.sender?.name}: ${message.text}`);
|
|
140
|
+
senderModel: 'User',
|
|
141
|
+
senderId: 'user-789',
|
|
142
|
+
text: 'Hello there!',
|
|
143
|
+
targetUserId: 'org-321', // Target recipient ID (userId, organizationId, applicationId, etc.)
|
|
160
144
|
});
|
|
161
145
|
```
|
|
162
146
|
|
|
163
|
-
The populated sender data is available in the `sender` property of each message. The implementation uses a batched approach that:
|
|
164
|
-
|
|
165
|
-
1. Fetches messages using MongoDB aggregation
|
|
166
|
-
2. Groups messages by sender model type
|
|
167
|
-
3. Loads all senders for each model type in a single query per model
|
|
168
|
-
4. Attaches sender data to each message efficiently
|
|
169
|
-
|
|
170
|
-
This approach minimizes database queries and maximizes performance, even with multiple sender types.
|
|
171
|
-
|
|
172
|
-
#### Infinite Scroll Implementation
|
|
173
|
-
|
|
174
|
-
The returned data structure supports infinite scroll with cursor-based pagination:
|
|
175
|
-
|
|
176
|
-
```ts
|
|
177
|
-
// First load
|
|
178
|
-
const firstPage = await chat.services.messages.listMessagesWithSenders({
|
|
179
|
-
organizationId: 'org-123',
|
|
180
|
-
conversationId: 'conv-456',
|
|
181
|
-
populateSenders: true,
|
|
182
|
-
limit: 20 // Number of messages per "page"
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
// Display messages in your UI
|
|
186
|
-
displayMessages(firstPage.items);
|
|
187
|
-
|
|
188
|
-
// When user scrolls and needs more messages
|
|
189
|
-
if (firstPage.hasMore) {
|
|
190
|
-
// Load next batch using the cursor
|
|
191
|
-
const nextPage = await chat.services.messages.listMessagesWithSenders({
|
|
192
|
-
organizationId: 'org-123',
|
|
193
|
-
conversationId: 'conv-456',
|
|
194
|
-
populateSenders: true,
|
|
195
|
-
limit: 20,
|
|
196
|
-
cursor: firstPage.nextCursor // Pass the cursor from previous batch
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
// Append these messages to your UI
|
|
200
|
-
displayMessages([...existingMessages, ...nextPage.items]);
|
|
201
|
-
|
|
202
|
-
// Store the new cursor for further loading
|
|
203
|
-
nextCursor = nextPage.nextCursor;
|
|
204
|
-
hasMore = nextPage.hasMore;
|
|
205
|
-
}
|
|
206
|
-
```
|
|
207
|
-
|
|
208
|
-
The method returns messages sorted by creation time (newest first), making it ideal for chat interfaces where newer messages appear at the bottom. The cursor-based pagination ensures efficient loading of large conversation histories.
|
|
209
|
-
|
|
210
|
-
The type `MessageWithSender` is exported from the package for proper TypeScript support.
|
|
211
|
-
|
|
212
|
-
## Models
|
|
213
|
-
|
|
214
|
-
The package exports the following model types which can be imported directly:
|
|
215
|
-
|
|
216
|
-
```ts
|
|
217
|
-
import {
|
|
218
|
-
// Model interfaces (document structures)
|
|
219
|
-
type IConversation,
|
|
220
|
-
type IMessage,
|
|
221
|
-
|
|
222
|
-
// Mongoose model types
|
|
223
|
-
type ConversationModel,
|
|
224
|
-
type MessageModel,
|
|
225
|
-
|
|
226
|
-
// Extended types
|
|
227
|
-
type MessageWithSender // Message with populated sender data
|
|
228
|
-
} from 'wexa-chat';
|
|
229
|
-
```
|
|
230
|
-
|
|
231
|
-
To access the actual models:
|
|
232
|
-
|
|
233
|
-
```ts
|
|
234
|
-
// Initialize the chat system
|
|
235
|
-
const chat = await initChat(mongoose, options);
|
|
236
|
-
|
|
237
|
-
// Access models
|
|
238
|
-
const { Conversation, Message } = chat.models;
|
|
239
|
-
|
|
240
|
-
// Example usage
|
|
241
|
-
const conversations = await Conversation.find({ organizationId }).lean();
|
|
242
|
-
const messages = await Message.find({ conversationId }).sort({ createdAt: -1 }).lean();
|
|
243
|
-
```
|
|
244
|
-
|
|
245
147
|
## Browser client
|
|
246
148
|
|
|
247
149
|
Entry: `wexa-chat/client` exports `SocketProvider`, `useSocket`, `getSocketManager`, and `type ChatEvent`.
|
|
@@ -263,132 +165,72 @@ Use in components:
|
|
|
263
165
|
import { useEffect } from 'react';
|
|
264
166
|
import { useSocket, type ChatEvent } from 'wexa-chat/client';
|
|
265
167
|
|
|
266
|
-
export function ChatWindow({ conversationId }: { conversationId: string }) {
|
|
168
|
+
export function ChatWindow({ conversationId, otherUserId }: { conversationId: string, otherUserId: string }) {
|
|
267
169
|
const { isConnected, send, onEvent } = useSocket();
|
|
268
170
|
|
|
269
171
|
useEffect(() => {
|
|
270
|
-
if (!
|
|
271
|
-
if (isConnected) send({ type: 'join-conversation', conversationId });
|
|
272
|
-
const off = onEvent((e: ChatEvent) => {
|
|
273
|
-
if (e.type === 'message:created' && e.message?.conversationId === conversationId) {
|
|
274
|
-
// update UI
|
|
275
|
-
}
|
|
276
|
-
});
|
|
277
|
-
return off;
|
|
278
|
-
}, [conversationId, isConnected, send, onEvent]);
|
|
279
|
-
|
|
280
|
-
return null;
|
|
281
|
-
}
|
|
282
|
-
```
|
|
283
|
-
|
|
284
|
-
You can also send:
|
|
285
|
-
|
|
286
|
-
```ts
|
|
287
|
-
send({ type: 'leave-conversation', conversationId });
|
|
288
|
-
send({ type: 'typing', conversationId, isTyping: true });
|
|
289
|
-
|
|
290
|
-
// New: User-based subscriptions
|
|
291
|
-
send({ type: 'subscribe-to-user', userId: 'user-123' });
|
|
292
|
-
send({ type: 'unsubscribe-from-user', userId: 'user-123' });
|
|
293
|
-
```
|
|
294
|
-
|
|
295
|
-
## User-Based Subscriptions
|
|
296
|
-
|
|
297
|
-
The chat core now supports subscribing to all messages for a specific user, allowing you to receive messages from all conversations involving that user without needing to join each conversation individually.
|
|
298
|
-
|
|
299
|
-
### Server-Side Usage
|
|
300
|
-
|
|
301
|
-
The transport layer exposes new methods for user-based subscriptions:
|
|
302
|
-
|
|
303
|
-
```ts
|
|
304
|
-
// Access the transport from your chat instance
|
|
305
|
-
const { transport } = await getChatInstance();
|
|
306
|
-
|
|
307
|
-
// Subscribe a socket to all messages for a user
|
|
308
|
-
transport.subscribeToUser(userId, socketId);
|
|
309
|
-
|
|
310
|
-
// Unsubscribe a socket from a user's messages
|
|
311
|
-
transport.unsubscribeFromUser(userId, socketId);
|
|
312
|
-
|
|
313
|
-
// Check if a socket is subscribed to a user
|
|
314
|
-
const isSubscribed = transport.isSubscribedToUser(userId, socketId);
|
|
315
|
-
|
|
316
|
-
// Fan out a message to all subscribers of a user
|
|
317
|
-
transport.fanoutToUser(userId, payload, (toSocketId, event) => {
|
|
318
|
-
// Your emit logic here
|
|
319
|
-
});
|
|
320
|
-
```
|
|
321
|
-
|
|
322
|
-
### Client-Side Usage
|
|
323
|
-
|
|
324
|
-
Subscribe to all messages for a user:
|
|
325
|
-
|
|
326
|
-
```tsx
|
|
327
|
-
import { useEffect } from 'react';
|
|
328
|
-
import { useSocket } from 'wexa-chat/client';
|
|
329
|
-
|
|
330
|
-
export function GlobalMessageListener({ userId }: { userId: string }) {
|
|
331
|
-
const { isConnected, send, onEvent } = useSocket();
|
|
332
|
-
|
|
333
|
-
// Subscribe to user messages
|
|
334
|
-
useEffect(() => {
|
|
335
|
-
if (isConnected && userId) {
|
|
336
|
-
send({ type: 'subscribe-to-user', userId });
|
|
337
|
-
}
|
|
172
|
+
if (!isConnected) return;
|
|
338
173
|
|
|
339
|
-
|
|
340
|
-
if (
|
|
341
|
-
|
|
174
|
+
const off = onEvent((e: ChatEvent) => {
|
|
175
|
+
if (e.type === 'message:received' && e.conversationId === conversationId) {
|
|
176
|
+
// update UI with new message
|
|
177
|
+
console.log('New message:', e.message);
|
|
342
178
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
// Listen for messages
|
|
347
|
-
useEffect(() => {
|
|
348
|
-
const off = onEvent((event) => {
|
|
349
|
-
if (event.type === 'message:created') {
|
|
350
|
-
// Handle message from any conversation involving the subscribed user
|
|
351
|
-
console.log('New message:', event.message);
|
|
179
|
+
if (e.type === 'typing' && e.conversationId === conversationId) {
|
|
180
|
+
// update typing indicator
|
|
181
|
+
console.log('Typing status:', e.state, 'from:', e.senderId);
|
|
352
182
|
}
|
|
353
183
|
});
|
|
354
184
|
|
|
355
185
|
return off;
|
|
356
|
-
}, [onEvent]);
|
|
186
|
+
}, [conversationId, isConnected, onEvent]);
|
|
187
|
+
|
|
188
|
+
// Send a message to the other user
|
|
189
|
+
const sendMessage = (text: string) => {
|
|
190
|
+
if (isConnected) {
|
|
191
|
+
send({
|
|
192
|
+
type: 'send-message',
|
|
193
|
+
conversationId,
|
|
194
|
+
targetUserId: otherUserId,
|
|
195
|
+
text
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// Send typing indicator
|
|
201
|
+
const sendTyping = (isTyping: boolean) => {
|
|
202
|
+
if (isConnected) {
|
|
203
|
+
send({
|
|
204
|
+
type: 'typing',
|
|
205
|
+
conversationId,
|
|
206
|
+
targetUserId: otherUserId,
|
|
207
|
+
isTyping
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
};
|
|
357
211
|
|
|
358
|
-
return null;
|
|
212
|
+
return null;
|
|
359
213
|
}
|
|
360
214
|
```
|
|
361
215
|
|
|
362
|
-
### Use Cases
|
|
363
|
-
|
|
364
|
-
- **Global notifications**: Receive notifications for all messages involving a user
|
|
365
|
-
- **Unread message tracking**: Track unread messages across all conversations
|
|
366
|
-
- **Real-time updates**: Keep conversation lists updated without joining each conversation
|
|
367
|
-
- **Background message sync**: Sync messages even when not actively viewing conversations
|
|
368
|
-
|
|
369
216
|
## Events
|
|
370
217
|
|
|
371
218
|
### Client → Server
|
|
372
219
|
|
|
373
|
-
- `
|
|
374
|
-
- `
|
|
375
|
-
- `typing` — `{ type: 'typing', conversationId, isTyping: boolean }`
|
|
376
|
-
- `subscribe-to-user` — `{ type: 'subscribe-to-user', userId }` *(New)*
|
|
377
|
-
- `unsubscribe-from-user` — `{ type: 'unsubscribe-from-user', userId }` *(New)*
|
|
220
|
+
- `send-message` — `{ type: 'send-message', conversationId, targetUserId, text }`
|
|
221
|
+
- `typing` — `{ type: 'typing', conversationId, targetUserId, isTyping: boolean }`
|
|
378
222
|
|
|
379
223
|
### Server → Client
|
|
380
224
|
|
|
381
|
-
- `message:
|
|
382
|
-
- `
|
|
383
|
-
- `typing` — `{ type: 'typing', conversationId, participantModel, participantId, state: 'start' | 'stop' }`
|
|
384
|
-
- `presence:join` / `presence:leave` — `{ type, organizationId, participantModel, participantId, at }`
|
|
225
|
+
- `message:received` — `{ type: 'message:received', message, conversationId, senderId }`
|
|
226
|
+
- `typing` — `{ type: 'typing', conversationId, senderId, state: 'start' | 'stop', at }`
|
|
385
227
|
|
|
386
228
|
If Redis is configured, events are pub/sub‑broadcast across instances.
|
|
387
229
|
|
|
388
230
|
## Build
|
|
389
231
|
|
|
390
232
|
```bash
|
|
391
|
-
|
|
233
|
+
pnpm build
|
|
392
234
|
```
|
|
393
235
|
|
|
394
236
|
Exports (from `package.json`):
|
|
@@ -406,8 +248,8 @@ Exports (from `package.json`):
|
|
|
406
248
|
|
|
407
249
|
- `MONGODB_URI`
|
|
408
250
|
- `REDIS_URL` or `REDIS_HOST`/`REDIS_PORT`/`REDIS_PASSWORD`/`REDIS_DB`
|
|
409
|
-
- Ensure `socket.path` matches the client (recommended `/
|
|
251
|
+
- Ensure `socket.path` matches the client (recommended `/api/socket`).
|
|
410
252
|
|
|
411
253
|
## License
|
|
412
254
|
|
|
413
|
-
MIT
|
|
255
|
+
MIT
|
package/dist/client/index.d.ts
CHANGED
|
@@ -1,19 +1,15 @@
|
|
|
1
1
|
import { ReactNode } from 'react';
|
|
2
2
|
|
|
3
3
|
type ChatEvent = {
|
|
4
|
-
type: 'message:
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
type: 'conversation:read';
|
|
4
|
+
type: 'message:received';
|
|
5
|
+
message: any;
|
|
6
|
+
conversationId: string;
|
|
8
7
|
[k: string]: any;
|
|
9
8
|
} | {
|
|
10
9
|
type: 'typing';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
[k: string]: any;
|
|
15
|
-
} | {
|
|
16
|
-
type: 'presence:leave';
|
|
10
|
+
conversationId: string;
|
|
11
|
+
senderId: string;
|
|
12
|
+
state: string;
|
|
17
13
|
[k: string]: any;
|
|
18
14
|
};
|
|
19
15
|
type Listener<T> = (v: T) => void;
|
package/dist/client/index.js
CHANGED
|
@@ -33,7 +33,7 @@ var SocketManager = class {
|
|
|
33
33
|
return;
|
|
34
34
|
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
|
35
35
|
const ws = new WebSocket(
|
|
36
|
-
`${protocol}://${window.location.host}/
|
|
36
|
+
`${protocol}://${window.location.host}/api/socket?userId=${encodeURIComponent(userId)}`
|
|
37
37
|
);
|
|
38
38
|
this.ws = ws;
|
|
39
39
|
this.connecting = true;
|
package/dist/client/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/client/socketManager.ts","../../src/client/SocketProvider.tsx"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["export type ChatEvent =\n | { type: 'message:created'; [k: string]: any }\n | { type: 'conversation:read'; [k: string]: any }\n | { type: 'typing'; [k: string]: any }\n | { type: 'presence:join'; [k: string]: any }\n | { type: 'presence:leave'; [k: string]: any };\n\ntype Listener<T> = (v: T) => void;\n\nclass SocketManager {\n private ws: WebSocket | null = null;\n private reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n private manualClose = false;\n private attempt = 0;\n private warmedUp = false;\n private connecting = false;\n\n private connListeners = new Set<Listener<boolean>>();\n private eventListeners = new Set<Listener<ChatEvent>>();\n\n private getUserId: () => string | undefined = () => undefined;\n\n constructor(getUserId?: () => string | undefined) {\n if (getUserId) this.getUserId = getUserId;\n }\n\n setGetUserId = (fn: () => string | undefined) => {\n this.getUserId = fn;\n };\n\n private notifyConn(v: boolean) {\n this.connListeners.forEach((l) => l(v));\n }\n private notifyEvent(e: ChatEvent) {\n this.eventListeners.forEach((l) => l(e));\n }\n\n addConnListener(l: Listener<boolean>) {\n this.connListeners.add(l);\n return () => this.connListeners.delete(l);\n }\n addEventListener(l: Listener<ChatEvent>) {\n this.eventListeners.add(l);\n return () => this.eventListeners.delete(l);\n }\n\n connect = async () => {\n const userId = this.getUserId();\n if (!userId || typeof window === 'undefined') return;\n\n // Prevent overlapping connect attempts\n if (this.connecting) return;\n\n if (!this.warmedUp) {\n try {\n await fetch('/api/socket', { method: 'GET', keepalive: true });\n } catch {}\n this.warmedUp = true;\n }\n\n if (\n this.ws &&\n (this.ws.readyState === WebSocket.OPEN ||\n this.ws.readyState === WebSocket.CONNECTING ||\n this.ws.readyState === WebSocket.CLOSING)\n )\n return;\n\n const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';\n const ws = new WebSocket(\n `${protocol}://${window.location.host}/ws?userId=${encodeURIComponent(userId)}`,\n );\n this.ws = ws;\n this.connecting = true;\n\n ws.onopen = () => {\n if (this.ws !== ws) return;\n this.attempt = 0;\n this.manualClose = false;\n this.notifyConn(true);\n this.connecting = false;\n };\n ws.onmessage = (msg) => {\n if (this.ws !== ws) return;\n try {\n this.notifyEvent(JSON.parse(msg.data as string));\n } catch {}\n };\n ws.onclose = () => {\n if (this.ws !== ws) return;\n this.notifyConn(false);\n this.ws = null;\n this.connecting = false;\n if (this.manualClose) return;\n this.attempt += 1;\n const delay = Math.min(5000, 500 * Math.pow(2, this.attempt));\n if (this.reconnectTimer) clearTimeout(this.reconnectTimer);\n this.reconnectTimer = setTimeout(this.connect, delay);\n };\n ws.onerror = () => {\n try {\n ws.close();\n } catch {}\n if (this.ws === ws) {\n this.connecting = false;\n }\n };\n };\n\n send = (payload: unknown) => {\n if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return false;\n try {\n this.ws.send(JSON.stringify(payload));\n return true;\n } catch {\n return false;\n }\n };\n\n close = () => {\n this.manualClose = true;\n if (this.reconnectTimer) clearTimeout(this.reconnectTimer);\n this.reconnectTimer = null;\n try {\n this.ws?.close();\n } catch {}\n this.ws = null;\n };\n}\n\nexport default SocketManager;\n\n// Singleton accessor that survives Next.js Fast Refresh by storing on globalThis\ndeclare const globalThis: typeof global & {\n __socketManagerSingleton?: SocketManager | null;\n};\n\nexport function getSocketManager(getUserId?: () => string | undefined) {\n if (!globalThis.__socketManagerSingleton) {\n globalThis.__socketManagerSingleton = new SocketManager(getUserId);\n } else if (getUserId) {\n globalThis.__socketManagerSingleton.setGetUserId(getUserId);\n }\n return globalThis.__socketManagerSingleton;\n}\n","'use client';\n\nimport { createContext, useContext, useEffect, useMemo, useState, useCallback, type ReactNode } from 'react';\nimport { getSocketManager } from './socketManager';\nimport type { ChatEvent } from './socketManager';\n\nexport type SocketCtxShape = {\n isConnected: boolean;\n send: (payload: unknown) => boolean;\n onEvent: (cb: (e: ChatEvent) => void) => () => void;\n};\n\nconst SocketCtx = createContext<SocketCtxShape | null>(null);\n\nexport function SocketProvider({\n children,\n getUserId,\n}: {\n children: ReactNode;\n getUserId: () => string | undefined;\n}) {\n const [isConnected, setIsConnected] = useState(false);\n\n // Singleton manager for this tab\n const mgr = useMemo(() => getSocketManager(getUserId), [getUserId]);\n\n useEffect(() => {\n // keep user id up to date and ensure connection\n mgr.setGetUserId(getUserId);\n if (getUserId()) mgr.connect();\n // Note: we intentionally DO NOT close the socket on unmount to avoid\n // duplicate connects under React Strict Mode/Fast Refresh.\n }, [mgr, getUserId]);\n\n useEffect(() => {\n const off = mgr.addConnListener(setIsConnected);\n return () => {\n off();\n };\n }, [mgr]);\n\n const sendFn = useCallback<SocketCtxShape['send']>((payload: unknown) => mgr.send(payload), [mgr]);\n const onEventFn = useCallback<SocketCtxShape['onEvent']>((cb: (e: ChatEvent) => void) => mgr.addEventListener(cb), [mgr]);\n\n const value = useMemo<SocketCtxShape>(\n () => ({\n isConnected,\n send: sendFn,\n onEvent: onEventFn,\n }),\n [isConnected, sendFn, onEventFn],\n );\n\n return <SocketCtx.Provider value={value}>{children}</SocketCtx.Provider>;\n}\n\nexport function useSocket() {\n const ctx = useContext(SocketCtx);\n if (!ctx) throw new Error('useSocket must be used within <SocketProvider />');\n return ctx;\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/client/socketManager.ts","../../src/client/SocketProvider.tsx"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["export type ChatEvent =\n | { type: 'message:received'; message: any; conversationId: string; [k: string]: any }\n | { type: 'typing'; conversationId: string; senderId: string; state: string; [k: string]: any };\n\ntype Listener<T> = (v: T) => void;\n\nclass SocketManager {\n private ws: WebSocket | null = null;\n private reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n private manualClose = false;\n private attempt = 0;\n private warmedUp = false;\n private connecting = false;\n\n private connListeners = new Set<Listener<boolean>>();\n private eventListeners = new Set<Listener<ChatEvent>>();\n\n private getUserId: () => string | undefined = () => undefined;\n\n constructor(getUserId?: () => string | undefined) {\n if (getUserId) this.getUserId = getUserId;\n }\n\n setGetUserId = (fn: () => string | undefined) => {\n this.getUserId = fn;\n };\n\n private notifyConn(v: boolean) {\n this.connListeners.forEach((l) => l(v));\n }\n private notifyEvent(e: ChatEvent) {\n this.eventListeners.forEach((l) => l(e));\n }\n\n addConnListener(l: Listener<boolean>) {\n this.connListeners.add(l);\n return () => this.connListeners.delete(l);\n }\n addEventListener(l: Listener<ChatEvent>) {\n this.eventListeners.add(l);\n return () => this.eventListeners.delete(l);\n }\n\n connect = async () => {\n const userId = this.getUserId();\n if (!userId || typeof window === 'undefined') return;\n\n // Prevent overlapping connect attempts\n if (this.connecting) return;\n\n if (!this.warmedUp) {\n try {\n await fetch('/api/socket', { method: 'GET', keepalive: true });\n } catch {}\n this.warmedUp = true;\n }\n\n if (\n this.ws &&\n (this.ws.readyState === WebSocket.OPEN ||\n this.ws.readyState === WebSocket.CONNECTING ||\n this.ws.readyState === WebSocket.CLOSING)\n )\n return;\n\n const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';\n const ws = new WebSocket(\n `${protocol}://${window.location.host}/api/socket?userId=${encodeURIComponent(userId)}`,\n );\n this.ws = ws;\n this.connecting = true;\n\n ws.onopen = () => {\n if (this.ws !== ws) return;\n this.attempt = 0;\n this.manualClose = false;\n this.notifyConn(true);\n this.connecting = false;\n };\n ws.onmessage = (msg) => {\n if (this.ws !== ws) return;\n try {\n this.notifyEvent(JSON.parse(msg.data as string));\n } catch {}\n };\n ws.onclose = () => {\n if (this.ws !== ws) return;\n this.notifyConn(false);\n this.ws = null;\n this.connecting = false;\n if (this.manualClose) return;\n this.attempt += 1;\n const delay = Math.min(5000, 500 * Math.pow(2, this.attempt));\n if (this.reconnectTimer) clearTimeout(this.reconnectTimer);\n this.reconnectTimer = setTimeout(this.connect, delay);\n };\n ws.onerror = () => {\n try {\n ws.close();\n } catch {}\n if (this.ws === ws) {\n this.connecting = false;\n }\n };\n };\n\n send = (payload: unknown) => {\n if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return false;\n try {\n this.ws.send(JSON.stringify(payload));\n return true;\n } catch {\n return false;\n }\n };\n\n close = () => {\n this.manualClose = true;\n if (this.reconnectTimer) clearTimeout(this.reconnectTimer);\n this.reconnectTimer = null;\n try {\n this.ws?.close();\n } catch {}\n this.ws = null;\n };\n}\n\nexport default SocketManager;\n\n// Singleton accessor that survives Next.js Fast Refresh by storing on globalThis\ndeclare const globalThis: typeof global & {\n __socketManagerSingleton?: SocketManager | null;\n};\n\nexport function getSocketManager(getUserId?: () => string | undefined) {\n if (!globalThis.__socketManagerSingleton) {\n globalThis.__socketManagerSingleton = new SocketManager(getUserId);\n } else if (getUserId) {\n globalThis.__socketManagerSingleton.setGetUserId(getUserId);\n }\n return globalThis.__socketManagerSingleton;\n}\n","'use client';\n\nimport { createContext, useContext, useEffect, useMemo, useState, useCallback, type ReactNode } from 'react';\nimport { getSocketManager } from './socketManager';\nimport type { ChatEvent } from './socketManager';\n\nexport type SocketCtxShape = {\n isConnected: boolean;\n send: (payload: unknown) => boolean;\n onEvent: (cb: (e: ChatEvent) => void) => () => void;\n};\n\nconst SocketCtx = createContext<SocketCtxShape | null>(null);\n\nexport function SocketProvider({\n children,\n getUserId,\n}: {\n children: ReactNode;\n getUserId: () => string | undefined;\n}) {\n const [isConnected, setIsConnected] = useState(false);\n\n // Singleton manager for this tab\n const mgr = useMemo(() => getSocketManager(getUserId), [getUserId]);\n\n useEffect(() => {\n // keep user id up to date and ensure connection\n mgr.setGetUserId(getUserId);\n if (getUserId()) mgr.connect();\n // Note: we intentionally DO NOT close the socket on unmount to avoid\n // duplicate connects under React Strict Mode/Fast Refresh.\n }, [mgr, getUserId]);\n\n useEffect(() => {\n const off = mgr.addConnListener(setIsConnected);\n return () => {\n off();\n };\n }, [mgr]);\n\n const sendFn = useCallback<SocketCtxShape['send']>((payload: unknown) => mgr.send(payload), [mgr]);\n const onEventFn = useCallback<SocketCtxShape['onEvent']>((cb: (e: ChatEvent) => void) => mgr.addEventListener(cb), [mgr]);\n\n const value = useMemo<SocketCtxShape>(\n () => ({\n isConnected,\n send: sendFn,\n onEvent: onEventFn,\n }),\n [isConnected, sendFn, onEventFn],\n );\n\n return <SocketCtx.Provider value={value}>{children}</SocketCtx.Provider>;\n}\n\nexport function useSocket() {\n const ctx = useContext(SocketCtx);\n if (!ctx) throw new Error('useSocket must be used within <SocketProvider />');\n return ctx;\n}\n"]}
|