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 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
+ [![npm version](https://badge.fury.io/js/payload-socket-plugin.svg)](https://www.npmjs.com/package/payload-socket-plugin)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![Node.js Version](https://img.shields.io/node/v/payload-socket-plugin.svg)](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).
@@ -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;
@@ -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
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
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
+ }