payload-socket-plugin 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +381 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +171 -0
- package/dist/initSocketIO.d.ts +18 -0
- package/dist/initSocketIO.js +40 -0
- package/dist/mock.d.ts +13 -0
- package/dist/mock.js +19 -0
- package/dist/socketManager.d.ts +47 -0
- package/dist/socketManager.js +377 -0
- package/dist/types.d.ts +113 -0
- package/dist/types.js +2 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Bibek Thapa
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
# Payload Socket Plugin
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/payload-socket-plugin)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
|
|
7
|
+
Real-time event broadcasting plugin for Payload CMS using Socket.IO with Redis support for multi-instance deployments.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- ✅ **Real-time Events**: Broadcast collection changes (create, update, delete) to connected clients
|
|
12
|
+
- ✅ **Redis Support**: Multi-instance synchronization using Redis adapter
|
|
13
|
+
- ✅ **Per-Collection Authorization**: Fine-grained control over who receives events
|
|
14
|
+
- ✅ **JWT Authentication**: Secure WebSocket connections using Payload's JWT tokens
|
|
15
|
+
- ✅ **TypeScript**: Full type safety with TypeScript definitions
|
|
16
|
+
- ✅ **Flexible Configuration**: Customize CORS, paths, and event handling
|
|
17
|
+
|
|
18
|
+
## Prerequisites
|
|
19
|
+
|
|
20
|
+
- **Node.js**: >= 20.0.0
|
|
21
|
+
- **Payload CMS**: >= 2.0.0
|
|
22
|
+
- **Redis** (optional): Required for multi-instance deployments
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install payload-socket-plugin
|
|
28
|
+
# or
|
|
29
|
+
yarn add payload-socket-plugin
|
|
30
|
+
# or
|
|
31
|
+
pnpm add payload-socket-plugin
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Install Socket.IO Client (for frontend)
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm install socket.io-client
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
### 1. Configure the Plugin
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
// payload.config.ts
|
|
46
|
+
import { buildConfig } from "payload/config";
|
|
47
|
+
import { socketPlugin } from "payload-socket-plugin";
|
|
48
|
+
|
|
49
|
+
export default buildConfig({
|
|
50
|
+
// ... other config
|
|
51
|
+
plugins: [
|
|
52
|
+
socketPlugin({
|
|
53
|
+
enabled: true,
|
|
54
|
+
redis: {
|
|
55
|
+
url: process.env.REDIS_URL,
|
|
56
|
+
},
|
|
57
|
+
socketIO: {
|
|
58
|
+
cors: {
|
|
59
|
+
origin: ["http://localhost:3000"],
|
|
60
|
+
credentials: true,
|
|
61
|
+
},
|
|
62
|
+
path: "/socket.io",
|
|
63
|
+
},
|
|
64
|
+
includeCollections: ["projects", "posts"],
|
|
65
|
+
authorize: {
|
|
66
|
+
projects: async (user, event) => {
|
|
67
|
+
// Only allow project owner to receive events
|
|
68
|
+
return user.id === event.doc.user;
|
|
69
|
+
},
|
|
70
|
+
posts: async (user, event) => {
|
|
71
|
+
// Allow everyone to receive public post events
|
|
72
|
+
return event.doc.isPublic || user.id === event.doc.user;
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
}),
|
|
76
|
+
],
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 2. Initialize Socket.IO Server
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
// server.ts
|
|
84
|
+
import express from "express";
|
|
85
|
+
import payload from "payload";
|
|
86
|
+
import { initSocketIO } from "payload-socket-plugin";
|
|
87
|
+
|
|
88
|
+
const app = express();
|
|
89
|
+
|
|
90
|
+
// Initialize Payload
|
|
91
|
+
await payload.init({
|
|
92
|
+
secret: process.env.PAYLOAD_SECRET,
|
|
93
|
+
express: app,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Start HTTP server
|
|
97
|
+
const server = app.listen(3000, () => {
|
|
98
|
+
console.log("Server running on port 3000");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Initialize Socket.IO
|
|
102
|
+
await initSocketIO(server);
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### 3. Connect from Client
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
// client.ts
|
|
109
|
+
import { io } from "socket.io-client";
|
|
110
|
+
|
|
111
|
+
const socket = io("http://localhost:3000", {
|
|
112
|
+
auth: {
|
|
113
|
+
token: "your-jwt-token", // Get from Payload login
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Subscribe to collection events
|
|
118
|
+
socket.emit("join-collection", "projects");
|
|
119
|
+
|
|
120
|
+
// Listen for events
|
|
121
|
+
socket.on("payload:event", (event) => {
|
|
122
|
+
console.log("Event received:", event);
|
|
123
|
+
// {
|
|
124
|
+
// type: 'update',
|
|
125
|
+
// collection: 'projects',
|
|
126
|
+
// id: '123',
|
|
127
|
+
// doc: { ... },
|
|
128
|
+
// user: { id: '456', email: 'user@example.com' },
|
|
129
|
+
// timestamp: '2024-01-01T00:00:00.000Z'
|
|
130
|
+
// }
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Configuration Options
|
|
135
|
+
|
|
136
|
+
### `RealtimeEventsPluginOptions`
|
|
137
|
+
|
|
138
|
+
| Option | Type | Default | Description |
|
|
139
|
+
| -------------------- | ---------- | ------- | ------------------------------------------------------- |
|
|
140
|
+
| `enabled` | `boolean` | `true` | Enable/disable the plugin |
|
|
141
|
+
| `includeCollections` | `string[]` | `[]` | Collections to enable real-time events for |
|
|
142
|
+
| `redis` | `object` | - | Redis configuration for multi-instance support |
|
|
143
|
+
| `socketIO` | `object` | - | Socket.IO server options (CORS, path, etc.) |
|
|
144
|
+
| `authorize` | `object` | - | Per-collection authorization handlers |
|
|
145
|
+
| `shouldEmit` | `function` | - | Filter function to determine if event should be emitted |
|
|
146
|
+
| `transformEvent` | `function` | - | Transform events before emitting |
|
|
147
|
+
|
|
148
|
+
## Authorization
|
|
149
|
+
|
|
150
|
+
Authorization handlers determine which users can receive events for specific documents.
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
import type { CollectionAuthorizationHandler } from "payload-socket-plugin";
|
|
154
|
+
|
|
155
|
+
const authorizeProject: CollectionAuthorizationHandler = async (
|
|
156
|
+
user,
|
|
157
|
+
event
|
|
158
|
+
) => {
|
|
159
|
+
// Admin can see all events
|
|
160
|
+
if (user.role === "admin") {
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Check if user owns the project
|
|
165
|
+
const project = await payload.findByID({
|
|
166
|
+
collection: "projects",
|
|
167
|
+
id: event.id as string,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return user.id === project.user;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Use in plugin config
|
|
174
|
+
socketPlugin({
|
|
175
|
+
authorize: {
|
|
176
|
+
projects: authorizeProject,
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Client Events
|
|
182
|
+
|
|
183
|
+
### Subscribing to Collections
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
// Subscribe to a single collection
|
|
187
|
+
socket.emit("join-collection", "projects");
|
|
188
|
+
|
|
189
|
+
// Subscribe to multiple collections
|
|
190
|
+
socket.emit("subscribe", ["projects", "posts"]);
|
|
191
|
+
|
|
192
|
+
// Unsubscribe
|
|
193
|
+
socket.emit("unsubscribe", ["projects"]);
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Listening for Events
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
// Listen to specific collection events
|
|
200
|
+
socket.on("payload:event", (event) => {
|
|
201
|
+
if (event.collection === "projects" && event.type === "update") {
|
|
202
|
+
// Handle project update
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Listen to all events
|
|
207
|
+
socket.on("payload:event:all", (event) => {
|
|
208
|
+
console.log("Any event:", event);
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Advanced Usage
|
|
213
|
+
|
|
214
|
+
### Custom Event Filtering
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
socketPlugin({
|
|
218
|
+
shouldEmit: (event) => {
|
|
219
|
+
// Only emit events for published documents
|
|
220
|
+
return event.doc?.status === "published";
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Event Transformation
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
socketPlugin({
|
|
229
|
+
transformEvent: (event) => {
|
|
230
|
+
// Remove sensitive data before emitting
|
|
231
|
+
const { doc, ...rest } = event;
|
|
232
|
+
return {
|
|
233
|
+
...rest,
|
|
234
|
+
doc: {
|
|
235
|
+
id: doc.id,
|
|
236
|
+
title: doc.title,
|
|
237
|
+
// Omit sensitive fields
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
## How It Works
|
|
245
|
+
|
|
246
|
+
```
|
|
247
|
+
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
|
|
248
|
+
│ Client │◄───────►│ Socket.IO │◄───────►│ Payload │
|
|
249
|
+
│ (Browser) │ WebSocket │ Server │ Hooks │ CMS │
|
|
250
|
+
└─────────────┘ └──────────────┘ └─────────────┘
|
|
251
|
+
│
|
|
252
|
+
▼
|
|
253
|
+
┌──────────────┐
|
|
254
|
+
│ Redis │
|
|
255
|
+
│ Adapter │
|
|
256
|
+
└──────────────┘
|
|
257
|
+
│
|
|
258
|
+
┌─────────┴─────────┐
|
|
259
|
+
▼ ▼
|
|
260
|
+
┌──────────┐ ┌──────────┐
|
|
261
|
+
│ Instance │ │ Instance │
|
|
262
|
+
│ 1 │ │ 2 │
|
|
263
|
+
└──────────┘ └──────────┘
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
**Flow:**
|
|
267
|
+
|
|
268
|
+
1. Plugin hooks into Payload's `afterChange` and `afterDelete` lifecycle events
|
|
269
|
+
2. When a document changes, the plugin creates an event payload
|
|
270
|
+
3. Event is broadcast via Socket.IO to all connected clients
|
|
271
|
+
4. Authorization handlers determine which users receive the event
|
|
272
|
+
5. Redis adapter ensures events sync across multiple server instances
|
|
273
|
+
|
|
274
|
+
## Environment Variables
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
# Required for Redis multi-instance support
|
|
278
|
+
REDIS_URL=redis://localhost:6379
|
|
279
|
+
|
|
280
|
+
# Optional: Payload configuration
|
|
281
|
+
PAYLOAD_SECRET=your-secret-key
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
## TypeScript Types
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
import type {
|
|
288
|
+
CollectionAuthorizationHandler,
|
|
289
|
+
RealtimeEventPayload,
|
|
290
|
+
AuthenticatedSocket,
|
|
291
|
+
EventType,
|
|
292
|
+
} from "payload-socket-plugin";
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
## Troubleshooting
|
|
296
|
+
|
|
297
|
+
### Connection Issues
|
|
298
|
+
|
|
299
|
+
**Problem**: Client can't connect to Socket.IO server
|
|
300
|
+
|
|
301
|
+
**Solutions**:
|
|
302
|
+
|
|
303
|
+
- Verify CORS settings in `socketIO.cors` configuration
|
|
304
|
+
- Check that `initSocketIO()` is called after starting the HTTP server
|
|
305
|
+
- Ensure the Socket.IO path matches between server and client (default: `/socket.io`)
|
|
306
|
+
- Verify JWT token is valid and not expired
|
|
307
|
+
|
|
308
|
+
### Events Not Received
|
|
309
|
+
|
|
310
|
+
**Problem**: Connected but not receiving events
|
|
311
|
+
|
|
312
|
+
**Solutions**:
|
|
313
|
+
|
|
314
|
+
- Check that you've subscribed to the collection: `socket.emit('join-collection', 'collectionName')`
|
|
315
|
+
- Verify the collection is in `includeCollections` array
|
|
316
|
+
- Check authorization handler - it may be blocking events for your user
|
|
317
|
+
- Ensure the event type (create/update/delete) is being triggered
|
|
318
|
+
|
|
319
|
+
### Redis Connection Issues
|
|
320
|
+
|
|
321
|
+
**Problem**: Redis adapter not working in multi-instance setup
|
|
322
|
+
|
|
323
|
+
**Solutions**:
|
|
324
|
+
|
|
325
|
+
- Verify `REDIS_URL` environment variable is set correctly
|
|
326
|
+
- Check Redis server is running and accessible
|
|
327
|
+
- Ensure both server instances use the same Redis URL
|
|
328
|
+
- Check Redis logs for connection errors
|
|
329
|
+
|
|
330
|
+
### TypeScript Errors
|
|
331
|
+
|
|
332
|
+
**Problem**: Type errors when using the plugin
|
|
333
|
+
|
|
334
|
+
**Solutions**:
|
|
335
|
+
|
|
336
|
+
- Ensure `payload-socket-plugin` types are installed
|
|
337
|
+
- Check that your `tsconfig.json` includes the plugin's types
|
|
338
|
+
- Verify Payload CMS version compatibility (>= 2.0.0)
|
|
339
|
+
|
|
340
|
+
## Performance Considerations
|
|
341
|
+
|
|
342
|
+
- **Redis**: Highly recommended for production multi-instance deployments
|
|
343
|
+
- **Authorization**: Keep authorization handlers lightweight - they run on every event
|
|
344
|
+
- **Event Filtering**: Use `shouldEmit` to reduce unnecessary events
|
|
345
|
+
- **Event Transformation**: Use `transformEvent` to minimize payload size
|
|
346
|
+
|
|
347
|
+
## Security Considerations
|
|
348
|
+
|
|
349
|
+
- **JWT Authentication**: All connections require valid Payload JWT tokens
|
|
350
|
+
- **Authorization Handlers**: Always implement proper authorization to prevent data leaks
|
|
351
|
+
- **CORS**: Configure CORS carefully to only allow trusted origins
|
|
352
|
+
- **Event Data**: Be cautious about sensitive data in events - use `transformEvent` to sanitize
|
|
353
|
+
|
|
354
|
+
## Known Limitations
|
|
355
|
+
|
|
356
|
+
- Only supports Payload CMS v2.x (v3.x support coming soon)
|
|
357
|
+
- Authorization handlers are called for each connected user on every event
|
|
358
|
+
- No built-in event replay or history mechanism
|
|
359
|
+
- Redis is required for multi-instance deployments
|
|
360
|
+
|
|
361
|
+
## Changelog
|
|
362
|
+
|
|
363
|
+
See [CHANGELOG.md](./CHANGELOG.md) for version history.
|
|
364
|
+
|
|
365
|
+
## License
|
|
366
|
+
|
|
367
|
+
MIT © [Bibek Thapa](https://github.com/beewhoo)
|
|
368
|
+
|
|
369
|
+
## Contributing
|
|
370
|
+
|
|
371
|
+
Contributions are welcome! Please open an issue or PR.
|
|
372
|
+
|
|
373
|
+
1. Fork the repository
|
|
374
|
+
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
|
375
|
+
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
|
376
|
+
4. Push to the branch (`git push origin feature/amazing-feature`)
|
|
377
|
+
5. Open a Pull Request
|
|
378
|
+
|
|
379
|
+
## Support
|
|
380
|
+
|
|
381
|
+
For issues and questions, please [open a GitHub issue](https://github.com/beewhoo/payload-socket-plugin/issues).
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Plugin } from "payload/config";
|
|
2
|
+
import { RealtimeEventsPluginOptions } from "./types";
|
|
3
|
+
/**
|
|
4
|
+
* Payload CMS Plugin for Real-time Events
|
|
5
|
+
*
|
|
6
|
+
* This plugin enables real-time event broadcasting for collection changes
|
|
7
|
+
* using Socket.IO with Redis adapter for multi-instance support.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { socketPlugin } from 'payload-socket-plugin';
|
|
12
|
+
*
|
|
13
|
+
* export default buildConfig({
|
|
14
|
+
* plugins: [
|
|
15
|
+
* socketPlugin({
|
|
16
|
+
* enabled: true,
|
|
17
|
+
* redis: {
|
|
18
|
+
* url: process.env.REDIS_URL,
|
|
19
|
+
* },
|
|
20
|
+
* socketIO: {
|
|
21
|
+
* cors: {
|
|
22
|
+
* origin: ['http://localhost:3000'],
|
|
23
|
+
* credentials: true,
|
|
24
|
+
* },
|
|
25
|
+
* },
|
|
26
|
+
* includeCollections: ['projects', 'actors'],
|
|
27
|
+
* authorize: {
|
|
28
|
+
* projects: async (user, event) => {
|
|
29
|
+
* // Your authorization logic
|
|
30
|
+
* return user.id === event.doc.user;
|
|
31
|
+
* }
|
|
32
|
+
* }
|
|
33
|
+
* }),
|
|
34
|
+
* ],
|
|
35
|
+
* });
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export declare const socketPlugin: (pluginOptions?: RealtimeEventsPluginOptions) => Plugin;
|
|
39
|
+
export * from "./types";
|
|
40
|
+
export { SocketIOManager } from "./socketManager";
|
|
41
|
+
export { initSocketIO } from "./initSocketIO";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.initSocketIO = exports.SocketIOManager = exports.socketPlugin = void 0;
|
|
18
|
+
const socketManager_1 = require("./socketManager");
|
|
19
|
+
/**
|
|
20
|
+
* Payload CMS Plugin for Real-time Events
|
|
21
|
+
*
|
|
22
|
+
* This plugin enables real-time event broadcasting for collection changes
|
|
23
|
+
* using Socket.IO with Redis adapter for multi-instance support.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```ts
|
|
27
|
+
* import { socketPlugin } from 'payload-socket-plugin';
|
|
28
|
+
*
|
|
29
|
+
* export default buildConfig({
|
|
30
|
+
* plugins: [
|
|
31
|
+
* socketPlugin({
|
|
32
|
+
* enabled: true,
|
|
33
|
+
* redis: {
|
|
34
|
+
* url: process.env.REDIS_URL,
|
|
35
|
+
* },
|
|
36
|
+
* socketIO: {
|
|
37
|
+
* cors: {
|
|
38
|
+
* origin: ['http://localhost:3000'],
|
|
39
|
+
* credentials: true,
|
|
40
|
+
* },
|
|
41
|
+
* },
|
|
42
|
+
* includeCollections: ['projects', 'actors'],
|
|
43
|
+
* authorize: {
|
|
44
|
+
* projects: async (user, event) => {
|
|
45
|
+
* // Your authorization logic
|
|
46
|
+
* return user.id === event.doc.user;
|
|
47
|
+
* }
|
|
48
|
+
* }
|
|
49
|
+
* }),
|
|
50
|
+
* ],
|
|
51
|
+
* });
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
const socketPlugin = (pluginOptions = {}) => {
|
|
55
|
+
return (incomingConfig) => {
|
|
56
|
+
// Default options
|
|
57
|
+
const options = {
|
|
58
|
+
enabled: true,
|
|
59
|
+
includeCollections: [],
|
|
60
|
+
...pluginOptions,
|
|
61
|
+
};
|
|
62
|
+
// If plugin is disabled, return config unchanged
|
|
63
|
+
if (options.enabled === false) {
|
|
64
|
+
return incomingConfig;
|
|
65
|
+
}
|
|
66
|
+
const socketManager = new socketManager_1.SocketIOManager(options);
|
|
67
|
+
/**
|
|
68
|
+
* Helper function to check if events should be emitted for a collection
|
|
69
|
+
*/
|
|
70
|
+
const shouldEmitForCollection = (collectionSlug) => {
|
|
71
|
+
// Only emit for collections explicitly included
|
|
72
|
+
if (options.includeCollections && options.includeCollections.length > 0) {
|
|
73
|
+
return options.includeCollections.includes(collectionSlug);
|
|
74
|
+
}
|
|
75
|
+
// If no collections specified, don't emit for any
|
|
76
|
+
return false;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Create event payload from hook arguments
|
|
80
|
+
*/
|
|
81
|
+
const createEventPayload = (type, collection, args) => {
|
|
82
|
+
return {
|
|
83
|
+
type,
|
|
84
|
+
collection,
|
|
85
|
+
id: args.doc?.id || args.id,
|
|
86
|
+
doc: type === "delete" ? undefined : args.doc,
|
|
87
|
+
user: args.req?.user
|
|
88
|
+
? {
|
|
89
|
+
id: args.req.user.id,
|
|
90
|
+
email: args.req.user.email,
|
|
91
|
+
collection: args.req.user.collection,
|
|
92
|
+
}
|
|
93
|
+
: undefined,
|
|
94
|
+
timestamp: new Date().toISOString(),
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Add hooks to collections
|
|
99
|
+
*/
|
|
100
|
+
const collectionsWithHooks = incomingConfig.collections?.map((collection) => {
|
|
101
|
+
// Skip if events should not be emitted for this collection
|
|
102
|
+
if (!shouldEmitForCollection(collection.slug)) {
|
|
103
|
+
return collection;
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
...collection,
|
|
107
|
+
hooks: {
|
|
108
|
+
...collection.hooks,
|
|
109
|
+
// After change hook - only emit for updates
|
|
110
|
+
afterChange: [
|
|
111
|
+
...(collection.hooks?.afterChange || []),
|
|
112
|
+
async (args) => {
|
|
113
|
+
try {
|
|
114
|
+
// Only emit events for updates, not creates
|
|
115
|
+
if (args.operation !== "update") {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const event = createEventPayload("update", collection.slug, args);
|
|
119
|
+
await socketManager.emitEvent(event);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
console.error(`Error emitting update event for ${collection.slug}:`, error);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
// After delete hook
|
|
127
|
+
afterDelete: [
|
|
128
|
+
...(collection.hooks?.afterDelete || []),
|
|
129
|
+
async (args) => {
|
|
130
|
+
try {
|
|
131
|
+
const event = createEventPayload("delete", collection.slug, args);
|
|
132
|
+
await socketManager.emitEvent(event);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
console.error(`Error emitting delete event for ${collection.slug}:`, error);
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}) || [];
|
|
142
|
+
/**
|
|
143
|
+
* Add onInit hook to initialize Socket.IO server
|
|
144
|
+
*/
|
|
145
|
+
const onInit = async (payload) => {
|
|
146
|
+
// Call original onInit if it exists
|
|
147
|
+
if (incomingConfig.onInit) {
|
|
148
|
+
await incomingConfig.onInit(payload);
|
|
149
|
+
}
|
|
150
|
+
// Initialize Socket.IO server
|
|
151
|
+
// The server instance is available after Payload initializes with Express
|
|
152
|
+
if (payload.express) {
|
|
153
|
+
// Store the socket manager for later initialization
|
|
154
|
+
// The HTTP server will be initialized in server.ts using initSocketIO()
|
|
155
|
+
payload.__socketManager = socketManager;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
return {
|
|
159
|
+
...incomingConfig,
|
|
160
|
+
collections: collectionsWithHooks,
|
|
161
|
+
onInit,
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
exports.socketPlugin = socketPlugin;
|
|
166
|
+
// Export types for external use
|
|
167
|
+
__exportStar(require("./types"), exports);
|
|
168
|
+
var socketManager_2 = require("./socketManager");
|
|
169
|
+
Object.defineProperty(exports, "SocketIOManager", { enumerable: true, get: function () { return socketManager_2.SocketIOManager; } });
|
|
170
|
+
var initSocketIO_1 = require("./initSocketIO");
|
|
171
|
+
Object.defineProperty(exports, "initSocketIO", { enumerable: true, get: function () { return initSocketIO_1.initSocketIO; } });
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Server as HTTPServer } from "http";
|
|
2
|
+
/**
|
|
3
|
+
* Initialize Socket.IO server with the HTTP server instance
|
|
4
|
+
* This should be called after the HTTP server is created
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { initSocketIO } from 'payload-socket-plugin';
|
|
9
|
+
*
|
|
10
|
+
* const server = app.listen(PORT, () => {
|
|
11
|
+
* console.log(`Server is running on port ${PORT}`);
|
|
12
|
+
* });
|
|
13
|
+
*
|
|
14
|
+
* // Initialize Socket.IO
|
|
15
|
+
* await initSocketIO(server);
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export declare function initSocketIO(httpServer: HTTPServer): Promise<void>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.initSocketIO = initSocketIO;
|
|
7
|
+
const payload_1 = __importDefault(require("payload"));
|
|
8
|
+
/**
|
|
9
|
+
* Initialize Socket.IO server with the HTTP server instance
|
|
10
|
+
* This should be called after the HTTP server is created
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { initSocketIO } from 'payload-socket-plugin';
|
|
15
|
+
*
|
|
16
|
+
* const server = app.listen(PORT, () => {
|
|
17
|
+
* console.log(`Server is running on port ${PORT}`);
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* // Initialize Socket.IO
|
|
21
|
+
* await initSocketIO(server);
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
async function initSocketIO(httpServer) {
|
|
25
|
+
try {
|
|
26
|
+
// Get the socket manager from payload instance
|
|
27
|
+
const socketManager = payload_1.default.__socketManager;
|
|
28
|
+
if (!socketManager) {
|
|
29
|
+
payload_1.default.logger.warn("Socket.IO manager not found. Make sure socketPlugin is configured.");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
// Initialize Socket.IO with the HTTP server
|
|
33
|
+
await socketManager.init(httpServer);
|
|
34
|
+
payload_1.default.logger.info("Socket.IO initialized successfully");
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
payload_1.default.logger.error("Failed to initialize Socket.IO:", error);
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
package/dist/mock.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock module for socket plugin
|
|
3
|
+
* Used by webpack to prevent bundling server-side Socket.IO code in the admin panel
|
|
4
|
+
*
|
|
5
|
+
* This is aliased in payload.config.ts webpack configuration:
|
|
6
|
+
* [socketPluginPath]: socketPluginMockPath
|
|
7
|
+
*/
|
|
8
|
+
import { Config } from "payload/config";
|
|
9
|
+
/**
|
|
10
|
+
* Mock plugin that does nothing
|
|
11
|
+
* The real plugin is only used on the server side
|
|
12
|
+
*/
|
|
13
|
+
export declare const socketPlugin: () => (config: Config) => Config;
|
package/dist/mock.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Mock module for socket plugin
|
|
4
|
+
* Used by webpack to prevent bundling server-side Socket.IO code in the admin panel
|
|
5
|
+
*
|
|
6
|
+
* This is aliased in payload.config.ts webpack configuration:
|
|
7
|
+
* [socketPluginPath]: socketPluginMockPath
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.socketPlugin = void 0;
|
|
11
|
+
/**
|
|
12
|
+
* Mock plugin that does nothing
|
|
13
|
+
* The real plugin is only used on the server side
|
|
14
|
+
*/
|
|
15
|
+
const socketPlugin = () => (config) => {
|
|
16
|
+
// Return config unchanged - no Socket.IO in admin panel
|
|
17
|
+
return config;
|
|
18
|
+
};
|
|
19
|
+
exports.socketPlugin = socketPlugin;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Server as SocketIOServer } from "socket.io";
|
|
2
|
+
import { Server as HTTPServer } from "http";
|
|
3
|
+
import { RealtimeEventsPluginOptions, RealtimeEventPayload } from "./types";
|
|
4
|
+
/**
|
|
5
|
+
* Socket.IO Manager for handling real-time events with Redis adapter
|
|
6
|
+
* Supports multiple Payload instances for production environments
|
|
7
|
+
*/
|
|
8
|
+
export declare class SocketIOManager {
|
|
9
|
+
private io;
|
|
10
|
+
private pubClient;
|
|
11
|
+
private subClient;
|
|
12
|
+
private options;
|
|
13
|
+
constructor(options: RealtimeEventsPluginOptions);
|
|
14
|
+
/**
|
|
15
|
+
* Initialize Socket.IO server with Redis adapter
|
|
16
|
+
*/
|
|
17
|
+
init(server: HTTPServer): Promise<SocketIOServer>;
|
|
18
|
+
/**
|
|
19
|
+
* Setup Redis adapter for multi-instance synchronization
|
|
20
|
+
*/
|
|
21
|
+
private setupRedisAdapter;
|
|
22
|
+
/**
|
|
23
|
+
* Setup authentication middleware for Socket.IO connections
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Setup Socket.IO authentication middleware
|
|
27
|
+
* Verifies JWT tokens sent from clients (e.g., next-app via socket.handshake.auth.token)
|
|
28
|
+
* Uses Payload CMS JWT verification and fetches user from database
|
|
29
|
+
*/
|
|
30
|
+
private setupAuthentication;
|
|
31
|
+
/**
|
|
32
|
+
* Setup connection event handlers
|
|
33
|
+
*/
|
|
34
|
+
private setupConnectionHandlers;
|
|
35
|
+
/**
|
|
36
|
+
* Emit a real-time event to all connected clients
|
|
37
|
+
*/
|
|
38
|
+
emitEvent(event: RealtimeEventPayload): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Get Socket.IO server instance
|
|
41
|
+
*/
|
|
42
|
+
getIO(): SocketIOServer | null;
|
|
43
|
+
/**
|
|
44
|
+
* Cleanup and close connections
|
|
45
|
+
*/
|
|
46
|
+
close(): Promise<void>;
|
|
47
|
+
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.SocketIOManager = void 0;
|
|
7
|
+
const socket_io_1 = require("socket.io");
|
|
8
|
+
const redis_adapter_1 = require("@socket.io/redis-adapter");
|
|
9
|
+
const ioredis_1 = __importDefault(require("ioredis"));
|
|
10
|
+
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
|
11
|
+
const payload_1 = __importDefault(require("payload"));
|
|
12
|
+
/**
|
|
13
|
+
* Socket.IO Manager for handling real-time events with Redis adapter
|
|
14
|
+
* Supports multiple Payload instances for production environments
|
|
15
|
+
*/
|
|
16
|
+
class SocketIOManager {
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.io = null;
|
|
19
|
+
this.pubClient = null;
|
|
20
|
+
this.subClient = null;
|
|
21
|
+
this.options = options;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Initialize Socket.IO server with Redis adapter
|
|
25
|
+
*/
|
|
26
|
+
async init(server) {
|
|
27
|
+
const { redis, socketIO = {} } = this.options;
|
|
28
|
+
// Create Socket.IO server
|
|
29
|
+
this.io = new socket_io_1.Server(server, {
|
|
30
|
+
path: socketIO.path || "/socket.io",
|
|
31
|
+
cors: socketIO.cors || {
|
|
32
|
+
origin: "*",
|
|
33
|
+
credentials: true,
|
|
34
|
+
},
|
|
35
|
+
...socketIO,
|
|
36
|
+
});
|
|
37
|
+
// Setup Redis adapter
|
|
38
|
+
if (redis) {
|
|
39
|
+
await this.setupRedisAdapter();
|
|
40
|
+
}
|
|
41
|
+
// Setup authentication middleware
|
|
42
|
+
this.setupAuthentication();
|
|
43
|
+
// Setup connection handlers
|
|
44
|
+
this.setupConnectionHandlers();
|
|
45
|
+
payload_1.default.logger.info("Socket.IO server initialized with real-time events plugin");
|
|
46
|
+
return this.io;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Setup Redis adapter for multi-instance synchronization
|
|
50
|
+
*/
|
|
51
|
+
async setupRedisAdapter() {
|
|
52
|
+
const redisUrl = process.env.REDIS_URL;
|
|
53
|
+
if (!redisUrl) {
|
|
54
|
+
payload_1.default.logger.warn("REDIS_URL not configured. Skipping Redis adapter setup.");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
this.pubClient = new ioredis_1.default(redisUrl, {
|
|
59
|
+
keyPrefix: "socket.io:",
|
|
60
|
+
retryStrategy: (times) => {
|
|
61
|
+
const delay = Math.min(times * 50, 2000);
|
|
62
|
+
return delay;
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
this.subClient = this.pubClient.duplicate();
|
|
66
|
+
await Promise.all([
|
|
67
|
+
new Promise((resolve) => this.pubClient.once("ready", resolve)),
|
|
68
|
+
new Promise((resolve) => this.subClient.once("ready", resolve)),
|
|
69
|
+
]);
|
|
70
|
+
this.io.adapter((0, redis_adapter_1.createAdapter)(this.pubClient, this.subClient));
|
|
71
|
+
payload_1.default.logger.info("Redis adapter configured for Socket.IO multi-instance support");
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
payload_1.default.logger.error("Failed to setup Redis adapter:", error);
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Setup authentication middleware for Socket.IO connections
|
|
80
|
+
*/
|
|
81
|
+
/**
|
|
82
|
+
* Setup Socket.IO authentication middleware
|
|
83
|
+
* Verifies JWT tokens sent from clients (e.g., next-app via socket.handshake.auth.token)
|
|
84
|
+
* Uses Payload CMS JWT verification and fetches user from database
|
|
85
|
+
*/
|
|
86
|
+
setupAuthentication() {
|
|
87
|
+
this.io.use(async (socket, next) => {
|
|
88
|
+
try {
|
|
89
|
+
const token = socket.handshake.auth.token;
|
|
90
|
+
if (!token) {
|
|
91
|
+
return next(new Error("Authentication token required"));
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const decoded = jsonwebtoken_1.default.verify(token, payload_1.default.secret);
|
|
95
|
+
// Fetch full user document from Payload
|
|
96
|
+
const userDoc = await payload_1.default.findByID({
|
|
97
|
+
collection: decoded.collection || "users",
|
|
98
|
+
id: decoded.id,
|
|
99
|
+
});
|
|
100
|
+
if (!userDoc) {
|
|
101
|
+
return next(new Error("User not found"));
|
|
102
|
+
}
|
|
103
|
+
// Attach user info
|
|
104
|
+
socket.user = {
|
|
105
|
+
id: userDoc.id,
|
|
106
|
+
email: userDoc.email,
|
|
107
|
+
collection: decoded.collection || "users",
|
|
108
|
+
role: userDoc.role,
|
|
109
|
+
};
|
|
110
|
+
next();
|
|
111
|
+
}
|
|
112
|
+
catch (jwtError) {
|
|
113
|
+
return next(new Error("Invalid authentication token"));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
payload_1.default.logger.error("Socket authentication error:", error);
|
|
118
|
+
next(new Error("Authentication failed"));
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Setup connection event handlers
|
|
124
|
+
*/
|
|
125
|
+
setupConnectionHandlers() {
|
|
126
|
+
this.io.on("connection", (socket) => {
|
|
127
|
+
payload_1.default.logger.info(`Client connected: ${socket.id}, User: ${socket.user?.email || socket.user?.id}`);
|
|
128
|
+
// Allow clients to subscribe to specific collections
|
|
129
|
+
socket.on("subscribe", (collections) => {
|
|
130
|
+
const collectionList = Array.isArray(collections)
|
|
131
|
+
? collections
|
|
132
|
+
: [collections];
|
|
133
|
+
collectionList.forEach((collection) => {
|
|
134
|
+
socket.join(`collection:${collection}`);
|
|
135
|
+
payload_1.default.logger.info(`Client ${socket.id} subscribed to collection: ${collection}`);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
// // Allow clients to unsubscribe from collections
|
|
139
|
+
socket.on("unsubscribe", (collections) => {
|
|
140
|
+
const collectionList = Array.isArray(collections)
|
|
141
|
+
? collections
|
|
142
|
+
: [collections];
|
|
143
|
+
collectionList.forEach((collection) => {
|
|
144
|
+
socket.leave(`collection:${collection}`);
|
|
145
|
+
payload_1.default.logger.info(`Client ${socket.id} unsubscribed from collection: ${collection}`);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
// Allow clients to join collection rooms to receive update events
|
|
149
|
+
socket.on("join-collection", (collection) => {
|
|
150
|
+
const roomName = `collection:${collection}`;
|
|
151
|
+
socket.join(roomName);
|
|
152
|
+
payload_1.default.logger.info(`Client ${socket.id} (${socket.user?.email}) joined collection room: ${roomName}`);
|
|
153
|
+
});
|
|
154
|
+
// Project room handlers for presence tracking
|
|
155
|
+
socket.on("join-project", async (projectId) => {
|
|
156
|
+
if (!projectId) {
|
|
157
|
+
payload_1.default.logger.warn(`Client ${socket.id} tried to join project without ID`);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// Check if user has permission to join this project
|
|
161
|
+
try {
|
|
162
|
+
const project = await payload_1.default.findByID({
|
|
163
|
+
collection: "projects",
|
|
164
|
+
id: projectId,
|
|
165
|
+
depth: 0,
|
|
166
|
+
});
|
|
167
|
+
const projectOwnerId = typeof project.user === "string"
|
|
168
|
+
? project.user
|
|
169
|
+
: project.user?.id;
|
|
170
|
+
const isOwner = socket.user.id === projectOwnerId;
|
|
171
|
+
// Check if user has editor invitation
|
|
172
|
+
const invitation = await payload_1.default.find({
|
|
173
|
+
collection: "projectInvitations",
|
|
174
|
+
depth: 0,
|
|
175
|
+
where: {
|
|
176
|
+
user: { equals: socket.user.id },
|
|
177
|
+
status: { equals: "accepted" },
|
|
178
|
+
project: { equals: projectId },
|
|
179
|
+
role: { equals: "editor" },
|
|
180
|
+
},
|
|
181
|
+
limit: 1,
|
|
182
|
+
});
|
|
183
|
+
const hasEditorInvite = invitation.docs.length > 0;
|
|
184
|
+
// Only allow owner, users with editor invitation
|
|
185
|
+
if (!isOwner && !hasEditorInvite) {
|
|
186
|
+
payload_1.default.logger.warn(`Client ${socket.id} (${socket.user?.email}) denied access to project ${projectId} - no editor permission`);
|
|
187
|
+
socket.emit("join-project-error", {
|
|
188
|
+
message: "You need editor access to join this project room",
|
|
189
|
+
});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
payload_1.default.logger.error("Error checking project permissions:", error);
|
|
195
|
+
socket.emit("join-project-error", {
|
|
196
|
+
message: "Failed to verify project permissions",
|
|
197
|
+
});
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const roomName = `project:${projectId}:room`;
|
|
201
|
+
await socket.join(roomName);
|
|
202
|
+
payload_1.default.logger.info(`Client ${socket.id} (${socket.user?.email || socket.user?.id}) joined project: ${projectId}, room: ${roomName}`);
|
|
203
|
+
// Debug: Log all rooms this socket is in
|
|
204
|
+
payload_1.default.logger.info(`Socket ${socket.id} is now in rooms: ${Array.from(socket.rooms).join(", ")}`);
|
|
205
|
+
// Get all sockets in this project room
|
|
206
|
+
const socketsInRoom = await this.io.in(roomName).fetchSockets();
|
|
207
|
+
// Build list of active users
|
|
208
|
+
const activeUsers = socketsInRoom
|
|
209
|
+
.map((s) => {
|
|
210
|
+
if (s.user) {
|
|
211
|
+
return {
|
|
212
|
+
id: s.user.id,
|
|
213
|
+
email: s.user.email || undefined,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
})
|
|
218
|
+
.filter((u) => u !== null);
|
|
219
|
+
// Remove duplicates (same user, multiple tabs)
|
|
220
|
+
const uniqueUsers = Array.from(new Map(activeUsers.map((u) => [u.id, u])).values());
|
|
221
|
+
// Send current active users to the joining client
|
|
222
|
+
socket.emit("project:active-users", uniqueUsers);
|
|
223
|
+
// Notify others in the room that a new user joined
|
|
224
|
+
socket.to(roomName).emit("project:user-joined", {
|
|
225
|
+
id: socket.user.id,
|
|
226
|
+
email: socket.user.email || undefined,
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
socket.on("leave-project", (projectId) => {
|
|
230
|
+
if (!projectId)
|
|
231
|
+
return;
|
|
232
|
+
const roomName = `project:${projectId}:room`;
|
|
233
|
+
socket.leave(roomName);
|
|
234
|
+
payload_1.default.logger.info(`Client ${socket.id} left project: ${projectId}`);
|
|
235
|
+
// Notify others that user left
|
|
236
|
+
socket.to(roomName).emit("project:user-left", socket.user.id);
|
|
237
|
+
});
|
|
238
|
+
socket.on("kick-user", async (data) => {
|
|
239
|
+
const { projectId, userId } = data;
|
|
240
|
+
if (!projectId || !userId) {
|
|
241
|
+
payload_1.default.logger.warn(`Client ${socket.id} tried to kick user without projectId or userId`);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
// Verify the kicker is the project owner
|
|
245
|
+
try {
|
|
246
|
+
const project = await payload_1.default.findByID({
|
|
247
|
+
collection: "projects",
|
|
248
|
+
id: projectId,
|
|
249
|
+
});
|
|
250
|
+
const projectOwnerId = typeof project.user === "string"
|
|
251
|
+
? project.user
|
|
252
|
+
: project.user?.id;
|
|
253
|
+
if (socket.user.id !== projectOwnerId) {
|
|
254
|
+
payload_1.default.logger.warn(`Client ${socket.id} (${socket.user?.email}) tried to kick user but is not the owner`);
|
|
255
|
+
socket.emit("kick-error", {
|
|
256
|
+
message: "Only the project owner can kick users",
|
|
257
|
+
});
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
// Find all sockets for the user to kick
|
|
261
|
+
const roomName = `project:${projectId}:room`;
|
|
262
|
+
const socketsInRoom = await this.io.in(roomName).fetchSockets();
|
|
263
|
+
let kicked = false;
|
|
264
|
+
for (const s of socketsInRoom) {
|
|
265
|
+
const socketUser = s.user;
|
|
266
|
+
if (socketUser && socketUser.id === userId) {
|
|
267
|
+
// Emit kick event to the user being kicked
|
|
268
|
+
s.emit("kicked-from-project", {
|
|
269
|
+
projectId,
|
|
270
|
+
message: "You have been removed from this project by the owner",
|
|
271
|
+
});
|
|
272
|
+
// Remove them from the room
|
|
273
|
+
s.leave(roomName);
|
|
274
|
+
kicked = true;
|
|
275
|
+
payload_1.default.logger.info(`User ${userId} (${socketUser.email}) was kicked from project ${projectId} by ${socket.user?.email}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (kicked) {
|
|
279
|
+
// Notify others that user was kicked
|
|
280
|
+
socket.to(roomName).emit("project:user-left", userId);
|
|
281
|
+
// Confirm to the kicker
|
|
282
|
+
socket.emit("kick-success", { userId });
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
socket.emit("kick-error", {
|
|
286
|
+
message: "User not found in project",
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
payload_1.default.logger.error("Error kicking user:", error);
|
|
292
|
+
socket.emit("kick-error", {
|
|
293
|
+
message: "Failed to kick user",
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
// Handle disconnection - clean up project presence
|
|
298
|
+
socket.on("disconnect", async () => {
|
|
299
|
+
payload_1.default.logger.info(`Client disconnected: ${socket.id}, User: ${socket.user?.email || socket.user?.id}`);
|
|
300
|
+
// Notify all project rooms that this user left
|
|
301
|
+
// Socket.IO automatically tracks which rooms the socket was in
|
|
302
|
+
const rooms = Array.from(socket.rooms);
|
|
303
|
+
for (const room of rooms) {
|
|
304
|
+
// Only process project rooms (skip the socket's own room)
|
|
305
|
+
if (room.startsWith("project:") && socket.user) {
|
|
306
|
+
socket.to(room).emit("project:user-left", socket.user.id);
|
|
307
|
+
payload_1.default.logger.info(`Notified room ${room} that user ${socket.user.id} left`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Emit a real-time event to all connected clients
|
|
315
|
+
*/
|
|
316
|
+
async emitEvent(event) {
|
|
317
|
+
if (!this.io) {
|
|
318
|
+
payload_1.default.logger.warn("Socket.IO server not initialized, cannot emit event");
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const { authorize, shouldEmit, transformEvent } = this.options;
|
|
322
|
+
// Check if event should be emitted
|
|
323
|
+
if (shouldEmit && !shouldEmit(event)) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
// Transform event if transformer is provided
|
|
327
|
+
const finalEvent = transformEvent ? transformEvent(event) : event;
|
|
328
|
+
// Emit to collection-specific room
|
|
329
|
+
const room = `collection:${event.collection}`;
|
|
330
|
+
// If authorization is required, emit to each socket individually
|
|
331
|
+
if (authorize) {
|
|
332
|
+
// Get the handler for this collection
|
|
333
|
+
const collectionHandler = authorize[event.collection];
|
|
334
|
+
if (collectionHandler) {
|
|
335
|
+
const sockets = await this.io.in(room).fetchSockets();
|
|
336
|
+
for (const socket of sockets) {
|
|
337
|
+
const authSocket = socket;
|
|
338
|
+
if (authSocket.user) {
|
|
339
|
+
const isAuthorized = await collectionHandler(authSocket.user, finalEvent);
|
|
340
|
+
if (isAuthorized) {
|
|
341
|
+
socket.emit("payload:event", finalEvent);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
// If no handler for this collection, don't emit (deny by default)
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
// No authorization configured - emit to all sockets in the room
|
|
350
|
+
this.io.to(room).emit("payload:event", finalEvent);
|
|
351
|
+
}
|
|
352
|
+
// Also emit to a global room for clients listening to all events
|
|
353
|
+
this.io.emit("payload:event:all", finalEvent);
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Get Socket.IO server instance
|
|
357
|
+
*/
|
|
358
|
+
getIO() {
|
|
359
|
+
return this.io;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Cleanup and close connections
|
|
363
|
+
*/
|
|
364
|
+
async close() {
|
|
365
|
+
if (this.io) {
|
|
366
|
+
this.io.close();
|
|
367
|
+
}
|
|
368
|
+
if (this.pubClient) {
|
|
369
|
+
await this.pubClient.quit();
|
|
370
|
+
}
|
|
371
|
+
if (this.subClient) {
|
|
372
|
+
await this.subClient.quit();
|
|
373
|
+
}
|
|
374
|
+
payload_1.default.logger.info("Socket.IO server closed");
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
exports.SocketIOManager = SocketIOManager;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Socket } from "socket.io";
|
|
2
|
+
/**
|
|
3
|
+
* Event types that can be emitted
|
|
4
|
+
*/
|
|
5
|
+
export type EventType = "create" | "update" | "delete";
|
|
6
|
+
/**
|
|
7
|
+
* Payload for real-time events
|
|
8
|
+
*/
|
|
9
|
+
export interface RealtimeEventPayload {
|
|
10
|
+
/** Type of event */
|
|
11
|
+
type: EventType;
|
|
12
|
+
/** Collection slug */
|
|
13
|
+
collection: string;
|
|
14
|
+
/** Document ID */
|
|
15
|
+
id: string | number;
|
|
16
|
+
/** Document data (for create/update events) */
|
|
17
|
+
doc?: any;
|
|
18
|
+
/** User who triggered the event */
|
|
19
|
+
user?: {
|
|
20
|
+
id: string | number;
|
|
21
|
+
email?: string;
|
|
22
|
+
collection?: string;
|
|
23
|
+
};
|
|
24
|
+
/** Timestamp of the event */
|
|
25
|
+
timestamp: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Socket.IO server instance with authentication
|
|
29
|
+
*/
|
|
30
|
+
export interface AuthenticatedSocket extends Socket {
|
|
31
|
+
user?: {
|
|
32
|
+
id: string | number;
|
|
33
|
+
email?: string;
|
|
34
|
+
collection?: string;
|
|
35
|
+
role?: string;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Authorization handler for a specific collection
|
|
40
|
+
*/
|
|
41
|
+
export type CollectionAuthorizationHandler = (user: any, event: RealtimeEventPayload) => Promise<boolean>;
|
|
42
|
+
/**
|
|
43
|
+
* Plugin configuration options
|
|
44
|
+
*/
|
|
45
|
+
export interface RealtimeEventsPluginOptions {
|
|
46
|
+
/**
|
|
47
|
+
* Enable/disable the plugin
|
|
48
|
+
* @default true
|
|
49
|
+
*/
|
|
50
|
+
enabled?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Collections to include for real-time events
|
|
53
|
+
* Only these collections will have real-time events enabled
|
|
54
|
+
* If not provided or empty, no collections will have real-time events
|
|
55
|
+
*/
|
|
56
|
+
includeCollections?: string[];
|
|
57
|
+
/**
|
|
58
|
+
* Redis configuration for multi-instance support
|
|
59
|
+
* Uses REDIS_URL environment variable
|
|
60
|
+
*/
|
|
61
|
+
redis?: {
|
|
62
|
+
/** Redis connection URL - uses process.env.REDIS_URL */
|
|
63
|
+
url?: string;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Socket.IO server options
|
|
67
|
+
*/
|
|
68
|
+
socketIO?: {
|
|
69
|
+
/** CORS configuration */
|
|
70
|
+
cors?: {
|
|
71
|
+
origin?: string | string[] | ((origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => void);
|
|
72
|
+
credentials?: boolean;
|
|
73
|
+
};
|
|
74
|
+
/** Path for Socket.IO endpoint */
|
|
75
|
+
path?: string;
|
|
76
|
+
/** Additional Socket.IO server options */
|
|
77
|
+
[key: string]: any;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Custom authentication function
|
|
81
|
+
* If not provided, uses Payload's built-in JWT authentication
|
|
82
|
+
*/
|
|
83
|
+
authenticate?: (socket: Socket, payload: any) => Promise<any>;
|
|
84
|
+
/**
|
|
85
|
+
* Authorization handlers per collection
|
|
86
|
+
* Map of collection slug to authorization handler function
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```ts
|
|
90
|
+
* authorize: {
|
|
91
|
+
* projects: async (user, event) => {
|
|
92
|
+
* // Check if user can receive this project event
|
|
93
|
+
* return user.id === event.doc.user;
|
|
94
|
+
* },
|
|
95
|
+
* actors: async (user, event) => {
|
|
96
|
+
* // Check if user can receive this actor event
|
|
97
|
+
* return user.id === event.doc.user;
|
|
98
|
+
* }
|
|
99
|
+
* }
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
authorize?: {
|
|
103
|
+
[collectionSlug: string]: CollectionAuthorizationHandler;
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Event filter function to determine if an event should be emitted
|
|
107
|
+
*/
|
|
108
|
+
shouldEmit?: (event: RealtimeEventPayload) => boolean;
|
|
109
|
+
/**
|
|
110
|
+
* Custom event transformer
|
|
111
|
+
*/
|
|
112
|
+
transformEvent?: (event: RealtimeEventPayload) => RealtimeEventPayload;
|
|
113
|
+
}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "payload-socket-plugin",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Real-time Socket.IO plugin for Payload CMS with Redis support",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"browser": "dist/mock.js",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"prepublishOnly": "npm run build",
|
|
15
|
+
"clean": "rm -rf dist"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"payload",
|
|
19
|
+
"payload-plugin",
|
|
20
|
+
"payload-cms",
|
|
21
|
+
"socketio",
|
|
22
|
+
"socket.io",
|
|
23
|
+
"realtime",
|
|
24
|
+
"websocket",
|
|
25
|
+
"redis",
|
|
26
|
+
"real-time-events"
|
|
27
|
+
],
|
|
28
|
+
"author": "Bibek Thapa",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20.0.0"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"payload": "^2.0.0"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@socket.io/redis-adapter": "^8.0.0",
|
|
38
|
+
"ioredis": "^5.3.0",
|
|
39
|
+
"jsonwebtoken": "^9.0.0",
|
|
40
|
+
"socket.io": "^4.6.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/jsonwebtoken": "^9.0.0",
|
|
44
|
+
"@types/node": "^20.0.0",
|
|
45
|
+
"payload": "^2.0.0",
|
|
46
|
+
"typescript": "^5.0.0"
|
|
47
|
+
},
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git@github.com:beewhoo/payload-socket-plugin.git"
|
|
51
|
+
},
|
|
52
|
+
"homepage": "https://github.com/beewhoo/payload-socket-plugin#readme",
|
|
53
|
+
"bugs": {
|
|
54
|
+
"url": "https://github.com/beewhoo/payload-socket-plugin/issues"
|
|
55
|
+
}
|
|
56
|
+
}
|