wexa-chat 0.1.32 → 0.1.34
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 +142 -72
- package/dist/client/index.d.ts +10 -6
- package/dist/client/index.js +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/index.d.cts +47 -37
- package/dist/index.d.ts +47 -37
- package/dist/index.js +232 -125
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +226 -120
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -1,24 +1,16 @@
|
|
|
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 used by the demo app.
|
|
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
|
-
|
|
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
|
|
8
|
+
This README mirrors the integration used in the Next.js demo app.
|
|
15
9
|
|
|
16
10
|
## Installation
|
|
17
11
|
|
|
18
12
|
```bash
|
|
19
13
|
npm install wexa-chat mongoose ioredis
|
|
20
|
-
# or with pnpm
|
|
21
|
-
pnpm add wexa-chat mongoose ioredis
|
|
22
14
|
```
|
|
23
15
|
|
|
24
16
|
## Server usage
|
|
@@ -37,13 +29,15 @@ export async function getChatInstance(httpServer?: HTTPServer) {
|
|
|
37
29
|
// Attach WS transport when server becomes available
|
|
38
30
|
if (httpServer && !(chatInstance.transport as any).socket) {
|
|
39
31
|
chatInstance = await initChat(mongoose, {
|
|
32
|
+
participantModels: ['User'],
|
|
33
|
+
memberRoles: ['member', 'admin'],
|
|
40
34
|
mongoUri: process.env.MONGODB_URI!,
|
|
41
35
|
// Optional Redis for cross‑instance fanout
|
|
42
36
|
redis: process.env.REDIS_URL || process.env.REDIS_HOST
|
|
43
37
|
? { url: process.env.REDIS_URL, host: process.env.REDIS_HOST, port: Number(process.env.REDIS_PORT || 6379) }
|
|
44
38
|
: undefined,
|
|
45
|
-
// IMPORTANT: must match the client
|
|
46
|
-
socket: { path: '/
|
|
39
|
+
// IMPORTANT: must match the client (recommended '/ws')
|
|
40
|
+
socket: { path: '/ws' },
|
|
47
41
|
enableTextSearch: true,
|
|
48
42
|
}, httpServer);
|
|
49
43
|
}
|
|
@@ -52,11 +46,13 @@ export async function getChatInstance(httpServer?: HTTPServer) {
|
|
|
52
46
|
|
|
53
47
|
// First‑time init; only pass socket when server exists
|
|
54
48
|
chatInstance = await initChat(mongoose, {
|
|
49
|
+
participantModels: ['User'],
|
|
50
|
+
memberRoles: ['member', 'admin'],
|
|
55
51
|
mongoUri: process.env.MONGODB_URI!,
|
|
56
52
|
redis: process.env.REDIS_URL || process.env.REDIS_HOST
|
|
57
53
|
? { url: process.env.REDIS_URL, host: process.env.REDIS_HOST, port: Number(process.env.REDIS_PORT || 6379) }
|
|
58
54
|
: undefined,
|
|
59
|
-
socket: httpServer ? { path: '/
|
|
55
|
+
socket: httpServer ? { path: '/ws' } : undefined,
|
|
60
56
|
enableTextSearch: true,
|
|
61
57
|
}, httpServer);
|
|
62
58
|
|
|
@@ -67,8 +63,8 @@ export async function getChatInstance(httpServer?: HTTPServer) {
|
|
|
67
63
|
### WebSocket transport
|
|
68
64
|
|
|
69
65
|
- Transport uses native `ws` and attaches an HTTP upgrade listener when `httpServer` is provided.
|
|
70
|
-
- Set `socket.path` to the URL path for upgrades. The client bundle expects `/
|
|
71
|
-
- Authentication: client connects to `ws(s)://host/
|
|
66
|
+
- Set `socket.path` to the URL path for upgrades. The client bundle expects `/ws`; configure the same on the server.
|
|
67
|
+
- Authentication: client connects to `ws(s)://host/ws?userId=...&userModel=User`. The server validates the handshake and associates the user.
|
|
72
68
|
|
|
73
69
|
### Next.js warm‑up route
|
|
74
70
|
|
|
@@ -89,7 +85,7 @@ export default async function handler(_req: NextApiRequest, res: NextApiResponse
|
|
|
89
85
|
}
|
|
90
86
|
```
|
|
91
87
|
|
|
92
|
-
The browser client performs a one
|
|
88
|
+
The browser client performs a one‑time GET of `/api/socket` before opening the WebSocket.
|
|
93
89
|
|
|
94
90
|
## Options overview (see `src/types/init.ts`)
|
|
95
91
|
|
|
@@ -100,6 +96,8 @@ export type SocketConfig = {
|
|
|
100
96
|
};
|
|
101
97
|
|
|
102
98
|
export type InitOptions = {
|
|
99
|
+
participantModels: string[]; // e.g. ['User']
|
|
100
|
+
memberRoles: string[]; // e.g. ['member', 'admin']
|
|
103
101
|
mongoUri: string; // MongoDB connection
|
|
104
102
|
enableTextSearch?: boolean;
|
|
105
103
|
redis?: { // optional
|
|
@@ -107,6 +105,7 @@ export type InitOptions = {
|
|
|
107
105
|
socketConnectTimeout?: number; keepAlive?: number;
|
|
108
106
|
};
|
|
109
107
|
socket?: SocketConfig; // WebSocket config
|
|
108
|
+
presence?: { enabled?: boolean; heartbeatSec?: number };
|
|
110
109
|
rateLimit?: { sendPerMin?: number; typingPerMin?: number };
|
|
111
110
|
}
|
|
112
111
|
```
|
|
@@ -122,26 +121,125 @@ export type InitOptions = {
|
|
|
122
121
|
- `searchByParticipantPair({ organizationId, a, b })`
|
|
123
122
|
|
|
124
123
|
- Messages
|
|
125
|
-
- `sendMessage({ organizationId, conversationId, senderModel, senderId, text,
|
|
124
|
+
- `sendMessage({ organizationId, conversationId, senderModel, senderId, text, kind?, parentMessageId?, rootThreadId? })`
|
|
126
125
|
- `listMessages({ organizationId, conversationId, limit?, cursor? })`
|
|
127
126
|
- `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
|
+
### Populating Message Senders with Infinite Scroll Support
|
|
132
|
+
|
|
133
|
+
The `listMessagesWithSenders` method allows you to retrieve messages with populated sender details, optimized for infinite scroll in chat interfaces. The implementation uses efficient batched lookups:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
// Basic usage
|
|
137
|
+
const messagesWithSenders = await chat.services.messages.listMessagesWithSenders({
|
|
138
|
+
organizationId: 'org-123',
|
|
139
|
+
conversationId: 'conv-456',
|
|
140
|
+
populateSenders: true // Required to enable population
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// With customization
|
|
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}`);
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The populated sender data is available in the `sender` property of each message. The implementation uses a batched approach that:
|
|
132
164
|
|
|
133
|
-
|
|
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:
|
|
134
175
|
|
|
135
176
|
```ts
|
|
136
|
-
//
|
|
137
|
-
await chat.services.messages.
|
|
177
|
+
// First load
|
|
178
|
+
const firstPage = await chat.services.messages.listMessagesWithSenders({
|
|
138
179
|
organizationId: 'org-123',
|
|
139
180
|
conversationId: 'conv-456',
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
text: 'Hello there!',
|
|
143
|
-
targetUserId: 'org-321', // Target recipient ID (userId, organizationId, applicationId, etc.)
|
|
181
|
+
populateSenders: true,
|
|
182
|
+
limit: 20 // Number of messages per "page"
|
|
144
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();
|
|
145
243
|
```
|
|
146
244
|
|
|
147
245
|
## Browser client
|
|
@@ -165,72 +263,44 @@ Use in components:
|
|
|
165
263
|
import { useEffect } from 'react';
|
|
166
264
|
import { useSocket, type ChatEvent } from 'wexa-chat/client';
|
|
167
265
|
|
|
168
|
-
export function ChatWindow({ conversationId
|
|
266
|
+
export function ChatWindow({ conversationId }: { conversationId: string }) {
|
|
169
267
|
const { isConnected, send, onEvent } = useSocket();
|
|
170
268
|
|
|
171
269
|
useEffect(() => {
|
|
172
|
-
if (!
|
|
173
|
-
|
|
270
|
+
if (!conversationId) return;
|
|
271
|
+
if (isConnected) send({ type: 'join-conversation', conversationId });
|
|
174
272
|
const off = onEvent((e: ChatEvent) => {
|
|
175
|
-
if (e.type === 'message:
|
|
176
|
-
// update UI
|
|
177
|
-
console.log('New message:', e.message);
|
|
178
|
-
}
|
|
179
|
-
if (e.type === 'typing' && e.conversationId === conversationId) {
|
|
180
|
-
// update typing indicator
|
|
181
|
-
console.log('Typing status:', e.state, 'from:', e.senderId);
|
|
273
|
+
if (e.type === 'message:created' && e.message?.conversationId === conversationId) {
|
|
274
|
+
// update UI
|
|
182
275
|
}
|
|
183
276
|
});
|
|
184
|
-
|
|
185
277
|
return off;
|
|
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
|
-
};
|
|
278
|
+
}, [conversationId, isConnected, send, onEvent]);
|
|
211
279
|
|
|
212
280
|
return null;
|
|
213
281
|
}
|
|
214
282
|
```
|
|
215
283
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
### Client → Server
|
|
284
|
+
You can also send:
|
|
219
285
|
|
|
220
|
-
|
|
221
|
-
|
|
286
|
+
```ts
|
|
287
|
+
send({ type: 'leave-conversation', conversationId });
|
|
288
|
+
send({ type: 'typing', conversationId, isTyping: true });
|
|
289
|
+
```
|
|
222
290
|
|
|
223
|
-
|
|
291
|
+
## Events (server → client)
|
|
224
292
|
|
|
225
|
-
- `message:
|
|
226
|
-
- `
|
|
293
|
+
- `message:created` — `{ type: 'message:created', message }`
|
|
294
|
+
- `conversation:read` — `{ type: 'conversation:read', conversationId, messageId, participantModel, participantId, at }`
|
|
295
|
+
- `typing` — `{ type: 'typing', conversationId, participantModel, participantId, state: 'start' | 'stop' }`
|
|
296
|
+
- `presence:join` / `presence:leave` — `{ type, organizationId, participantModel, participantId, at }`
|
|
227
297
|
|
|
228
298
|
If Redis is configured, events are pub/sub‑broadcast across instances.
|
|
229
299
|
|
|
230
300
|
## Build
|
|
231
301
|
|
|
232
302
|
```bash
|
|
233
|
-
|
|
303
|
+
npm run build
|
|
234
304
|
```
|
|
235
305
|
|
|
236
306
|
Exports (from `package.json`):
|
|
@@ -248,8 +318,8 @@ Exports (from `package.json`):
|
|
|
248
318
|
|
|
249
319
|
- `MONGODB_URI`
|
|
250
320
|
- `REDIS_URL` or `REDIS_HOST`/`REDIS_PORT`/`REDIS_PASSWORD`/`REDIS_DB`
|
|
251
|
-
- Ensure `socket.path` matches the client (recommended `/
|
|
321
|
+
- Ensure `socket.path` matches the client (recommended `/ws`).
|
|
252
322
|
|
|
253
323
|
## License
|
|
254
324
|
|
|
255
|
-
MIT
|
|
325
|
+
MIT
|
package/dist/client/index.d.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { ReactNode } from 'react';
|
|
2
2
|
|
|
3
3
|
type ChatEvent = {
|
|
4
|
-
type: 'message:
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
type: 'message:created';
|
|
5
|
+
[k: string]: any;
|
|
6
|
+
} | {
|
|
7
|
+
type: 'conversation:read';
|
|
7
8
|
[k: string]: any;
|
|
8
9
|
} | {
|
|
9
10
|
type: 'typing';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
[k: string]: any;
|
|
12
|
+
} | {
|
|
13
|
+
type: 'presence:join';
|
|
14
|
+
[k: string]: any;
|
|
15
|
+
} | {
|
|
16
|
+
type: 'presence:leave';
|
|
13
17
|
[k: string]: any;
|
|
14
18
|
};
|
|
15
19
|
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}/ws?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: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"]}
|
|
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"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -70,6 +70,14 @@ type SocketConfig$1 = {
|
|
|
70
70
|
};
|
|
71
71
|
};
|
|
72
72
|
type InitOptions = {
|
|
73
|
+
/**
|
|
74
|
+
* Models that can participate in conversations
|
|
75
|
+
*/
|
|
76
|
+
participantModels: string[];
|
|
77
|
+
/**
|
|
78
|
+
* Valid roles for conversation participants
|
|
79
|
+
*/
|
|
80
|
+
memberRoles: string[];
|
|
73
81
|
/**
|
|
74
82
|
* MongoDB connection URI
|
|
75
83
|
*/
|
|
@@ -83,9 +91,16 @@ type InitOptions = {
|
|
|
83
91
|
*/
|
|
84
92
|
redis?: RedisConfig;
|
|
85
93
|
/**
|
|
86
|
-
* Socket configuration
|
|
94
|
+
* Socket.IO configuration
|
|
87
95
|
*/
|
|
88
96
|
socket?: SocketConfig$1;
|
|
97
|
+
/**
|
|
98
|
+
* Presence tracking configuration
|
|
99
|
+
*/
|
|
100
|
+
presence?: {
|
|
101
|
+
enabled?: boolean;
|
|
102
|
+
heartbeatSec?: number;
|
|
103
|
+
};
|
|
89
104
|
/**
|
|
90
105
|
* Rate limiting configuration
|
|
91
106
|
*/
|
|
@@ -113,7 +128,6 @@ type SendMessageArgs = {
|
|
|
113
128
|
senderId: string;
|
|
114
129
|
text: string;
|
|
115
130
|
kind?: "text" | "system";
|
|
116
|
-
targetUserId?: string;
|
|
117
131
|
parentMessageId?: string;
|
|
118
132
|
rootThreadId?: string;
|
|
119
133
|
};
|
|
@@ -128,6 +142,13 @@ type ListMessagesArgs = {
|
|
|
128
142
|
modelMapping?: Record<string, string>;
|
|
129
143
|
};
|
|
130
144
|
};
|
|
145
|
+
type MarkReadArgs = {
|
|
146
|
+
organizationId: string;
|
|
147
|
+
conversationId: string;
|
|
148
|
+
participantModel: string;
|
|
149
|
+
participantId: string;
|
|
150
|
+
messageId: string;
|
|
151
|
+
};
|
|
131
152
|
type SearchMessagesArgs = {
|
|
132
153
|
organizationId: string;
|
|
133
154
|
query?: string;
|
|
@@ -260,7 +281,10 @@ declare function createConversationsService(models: {
|
|
|
260
281
|
* Message service hooks interface
|
|
261
282
|
*/
|
|
262
283
|
interface MessageServiceHooks {
|
|
263
|
-
onMessageCreated?: (message: IMessage
|
|
284
|
+
onMessageCreated?: (message: IMessage) => void;
|
|
285
|
+
onMessageRead?: (args: MarkReadArgs & {
|
|
286
|
+
at: Date;
|
|
287
|
+
}) => void;
|
|
264
288
|
}
|
|
265
289
|
/**
|
|
266
290
|
* Message with populated sender information
|
|
@@ -277,6 +301,7 @@ interface MessagesService {
|
|
|
277
301
|
listMessagesWithSenders(args: ListMessagesArgs & {
|
|
278
302
|
populateSenders: true;
|
|
279
303
|
}): Promise<PaginatedResult<MessageWithSender>>;
|
|
304
|
+
markRead(args: MarkReadArgs): Promise<void>;
|
|
280
305
|
}
|
|
281
306
|
/**
|
|
282
307
|
* Create messages service
|
|
@@ -290,58 +315,46 @@ declare function createMessagesService(models: {
|
|
|
290
315
|
}, hooks?: MessageServiceHooks): MessagesService;
|
|
291
316
|
|
|
292
317
|
/**
|
|
293
|
-
* In-memory subscription maps for
|
|
318
|
+
* In-memory subscription maps for conversations and sockets
|
|
294
319
|
*/
|
|
295
320
|
/**
|
|
296
|
-
* Subscribe a socket to a
|
|
297
|
-
* @param
|
|
321
|
+
* Subscribe a socket to a conversation
|
|
322
|
+
* @param conversationId The conversation ID to subscribe to
|
|
298
323
|
* @param socketId The socket ID to subscribe
|
|
299
324
|
*/
|
|
300
|
-
declare function
|
|
325
|
+
declare function subscribeLocal(conversationId: string, socketId: string): void;
|
|
301
326
|
/**
|
|
302
|
-
* Unsubscribe a socket from a
|
|
303
|
-
* @param
|
|
327
|
+
* Unsubscribe a socket from a conversation
|
|
328
|
+
* @param conversationId The conversation ID to unsubscribe from
|
|
304
329
|
* @param socketId The socket ID to unsubscribe
|
|
305
330
|
*/
|
|
306
|
-
declare function
|
|
331
|
+
declare function unsubscribeLocal(conversationId: string, socketId: string): void;
|
|
307
332
|
/**
|
|
308
333
|
* Clean up all subscriptions for a socket
|
|
309
334
|
* @param socketId The socket ID to clean up
|
|
310
335
|
*/
|
|
311
336
|
declare function cleanupLocal(socketId: string): void;
|
|
312
337
|
/**
|
|
313
|
-
*
|
|
314
|
-
* @param
|
|
315
|
-
* @param socketId The socket ID to check
|
|
316
|
-
*/
|
|
317
|
-
declare function isSubscribedToUser(userId: string, socketId: string): boolean;
|
|
318
|
-
/**
|
|
319
|
-
* Get all sockets subscribed to a user
|
|
320
|
-
* @param userId The user ID
|
|
321
|
-
*/
|
|
322
|
-
declare function getSocketsForUser(userId: string): Set<string>;
|
|
323
|
-
/**
|
|
324
|
-
* Fan out a message to all local subscribers of a user
|
|
325
|
-
* @param userId The user ID to fan out to
|
|
338
|
+
* Fan out a message to all local subscribers of a conversation
|
|
339
|
+
* @param conversationId The conversation ID to fan out to
|
|
326
340
|
* @param payload The payload to send
|
|
327
341
|
* @param emitter Function to emit events to socket IDs
|
|
328
342
|
*/
|
|
329
|
-
declare function
|
|
330
|
-
declare function subscribeLocal(_conversationId: string, _socketId: string): void;
|
|
331
|
-
declare function unsubscribeLocal(_conversationId: string, _socketId: string): void;
|
|
332
|
-
declare function fanoutLocal(_conversationId: string, payload: any, emitter: (toSocketId: string, event: any) => void): void;
|
|
343
|
+
declare function fanoutLocal(conversationId: string, payload: any, emitter: (toSocketId: string, event: any) => void): void;
|
|
333
344
|
|
|
334
345
|
interface SocketConfig {
|
|
335
346
|
path?: string;
|
|
336
347
|
}
|
|
337
348
|
interface SocketUser {
|
|
338
349
|
id: string;
|
|
350
|
+
model: string;
|
|
339
351
|
}
|
|
340
352
|
declare class SocketTransport {
|
|
341
353
|
private wss;
|
|
342
354
|
private transport;
|
|
343
355
|
private sockets;
|
|
344
356
|
private users;
|
|
357
|
+
private joinedConversations;
|
|
345
358
|
private authenticate?;
|
|
346
359
|
private ioShim;
|
|
347
360
|
constructor(server: Server, transport: Transport, config?: SocketConfig);
|
|
@@ -357,10 +370,6 @@ declare class SocketTransport {
|
|
|
357
370
|
* Get Socket.IO server instance
|
|
358
371
|
*/
|
|
359
372
|
getIO(): any;
|
|
360
|
-
/**
|
|
361
|
-
* Send a message to a specific user
|
|
362
|
-
*/
|
|
363
|
-
sendToUser(userId: string, payload: any): void;
|
|
364
373
|
/**
|
|
365
374
|
* Close Socket.IO server
|
|
366
375
|
*/
|
|
@@ -382,11 +391,6 @@ interface Transport {
|
|
|
382
391
|
unsubscribeLocal: typeof unsubscribeLocal;
|
|
383
392
|
cleanupLocal: typeof cleanupLocal;
|
|
384
393
|
fanoutLocal: typeof fanoutLocal;
|
|
385
|
-
subscribeToUser: typeof subscribeToUser;
|
|
386
|
-
unsubscribeFromUser: typeof unsubscribeFromUser;
|
|
387
|
-
isSubscribedToUser: typeof isSubscribedToUser;
|
|
388
|
-
getSocketsForUser: typeof getSocketsForUser;
|
|
389
|
-
fanoutToUser: typeof fanoutToUser;
|
|
390
394
|
publishToConversation: (conversationId: string, payload: any) => Promise<number>;
|
|
391
395
|
startRedisListener: (onEvent: (conversationId: string, payload: any) => void) => (() => void) | null;
|
|
392
396
|
}
|
|
@@ -473,6 +477,12 @@ declare function validateSendMessageArgs(args: SendMessageArgs): string | null;
|
|
|
473
477
|
* @returns Validation error message or null if valid
|
|
474
478
|
*/
|
|
475
479
|
declare function validateCreateConversationArgs(args: CreateConversationArgs): string | null;
|
|
480
|
+
/**
|
|
481
|
+
* Validates mark read arguments
|
|
482
|
+
* @param args Mark read arguments to validate
|
|
483
|
+
* @returns Validation error message or null if valid
|
|
484
|
+
*/
|
|
485
|
+
declare function validateMarkReadArgs(args: MarkReadArgs): string | null;
|
|
476
486
|
/**
|
|
477
487
|
* Validates pagination parameters
|
|
478
488
|
* @param limit Pagination limit
|
|
@@ -536,4 +546,4 @@ interface ChatServices {
|
|
|
536
546
|
*/
|
|
537
547
|
declare function initChat(mongooseInstance: typeof mongoose, options: InitOptions, server?: Server): Promise<ChatServices>;
|
|
538
548
|
|
|
539
|
-
export { type Actor, type ChatServices, type ConversationModel, type ConversationReadEvent, type ConversationsService, type CreateConversationArgs, type EnvironmentConfig, type IConversation, type IMessage, type InitOptions, type ListMessagesArgs, type MessageCreatedEvent, type MessageModel, type MessageServiceHooks, type MessageWithSender, type MessagesService, type PaginatedResult, type PresenceEvent, type RedisConfig, type SearchConversationsArgs, type SearchMessagesArgs, type SendMessageArgs, type SocketConfig$1 as SocketConfig, type Transport, type TransportConfig, type TypingEvent, createConversationId, createConversationModel, createConversationsService, createCursor, createMessageId, createMessageModel, createMessagesService, createModels, createPaginatedResponse, createThreadId, createTransport, ensureIndexes, generateId, getRedisConfig, initChat, modelRegistry, parseCursor, searchByParticipantPair, searchConversations, searchMessages, validateCreateConversationArgs, validateInitOptions, validatePagination, validateSendMessageArgs };
|
|
549
|
+
export { type Actor, type ChatServices, type ConversationModel, type ConversationReadEvent, type ConversationsService, type CreateConversationArgs, type EnvironmentConfig, type IConversation, type IMessage, type InitOptions, type ListMessagesArgs, type MarkReadArgs, type MessageCreatedEvent, type MessageModel, type MessageServiceHooks, type MessageWithSender, type MessagesService, type PaginatedResult, type PresenceEvent, type RedisConfig, type SearchConversationsArgs, type SearchMessagesArgs, type SendMessageArgs, type SocketConfig$1 as SocketConfig, type Transport, type TransportConfig, type TypingEvent, createConversationId, createConversationModel, createConversationsService, createCursor, createMessageId, createMessageModel, createMessagesService, createModels, createPaginatedResponse, createThreadId, createTransport, ensureIndexes, generateId, getRedisConfig, initChat, modelRegistry, parseCursor, searchByParticipantPair, searchConversations, searchMessages, validateCreateConversationArgs, validateInitOptions, validateMarkReadArgs, validatePagination, validateSendMessageArgs };
|