payload-socket-plugin 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,7 @@ Real-time event broadcasting plugin for Payload CMS using Socket.IO with Redis s
18
18
  ## Prerequisites
19
19
 
20
20
  - **Node.js**: >= 20.0.0
21
- - **Payload CMS**: >= 2.0.0
21
+ - **Payload CMS**: ^2.0.0 || ^3.0.0
22
22
  - **Redis** (optional): Required for multi-instance deployments
23
23
 
24
24
  ## Installation
@@ -61,15 +61,17 @@ export default buildConfig({
61
61
  },
62
62
  path: "/socket.io",
63
63
  },
64
- includeCollections: ["projects", "posts"],
64
+ includeCollections: ["posts", "users"],
65
65
  authorize: {
66
- projects: async (user, event) => {
67
- // Only allow project owner to receive events
68
- return user.id === event.doc.user;
69
- },
70
66
  posts: async (user, event) => {
71
67
  // Allow everyone to receive public post events
72
- return event.doc.isPublic || user.id === event.doc.user;
68
+ return (
69
+ event.doc.status === "published" || user.id === event.doc.author
70
+ );
71
+ },
72
+ users: async (user, event) => {
73
+ // Only allow user to receive their own events
74
+ return user.id === event.id;
73
75
  },
74
76
  },
75
77
  }),
@@ -115,16 +117,16 @@ const socket = io("http://localhost:3000", {
115
117
  });
116
118
 
117
119
  // Subscribe to collection events
118
- socket.emit("join-collection", "projects");
120
+ socket.emit("join-collection", "posts");
119
121
 
120
122
  // Listen for events
121
123
  socket.on("payload:event", (event) => {
122
124
  console.log("Event received:", event);
123
125
  // {
124
126
  // type: 'update',
125
- // collection: 'projects',
127
+ // collection: 'posts',
126
128
  // id: '123',
127
- // doc: { ... },
129
+ // doc: { title: 'My Post', status: 'published', ... },
128
130
  // user: { id: '456', email: 'user@example.com' },
129
131
  // timestamp: '2024-01-01T00:00:00.000Z'
130
132
  // }
@@ -144,6 +146,7 @@ socket.on("payload:event", (event) => {
144
146
  | `authorize` | `object` | - | Per-collection authorization handlers |
145
147
  | `shouldEmit` | `function` | - | Filter function to determine if event should be emitted |
146
148
  | `transformEvent` | `function` | - | Transform events before emitting |
149
+ | `onSocketConnection` | `function` | - | Custom event handlers for each socket connection |
147
150
 
148
151
  ## Authorization
149
152
 
@@ -152,28 +155,25 @@ Authorization handlers determine which users can receive events for specific doc
152
155
  ```typescript
153
156
  import type { CollectionAuthorizationHandler } from "payload-socket-plugin";
154
157
 
155
- const authorizeProject: CollectionAuthorizationHandler = async (
156
- user,
157
- event
158
- ) => {
158
+ const authorizePost: CollectionAuthorizationHandler = async (user, event) => {
159
159
  // Admin can see all events
160
160
  if (user.role === "admin") {
161
161
  return true;
162
162
  }
163
163
 
164
- // Check if user owns the project
165
- const project = await payload.findByID({
166
- collection: "projects",
164
+ // Check if post is published or user is the author
165
+ const post = await payload.findByID({
166
+ collection: "posts",
167
167
  id: event.id as string,
168
168
  });
169
169
 
170
- return user.id === project.user;
170
+ return post.status === "published" || user.id === post.author;
171
171
  };
172
172
 
173
173
  // Use in plugin config
174
174
  socketPlugin({
175
175
  authorize: {
176
- projects: authorizeProject,
176
+ posts: authorizePost,
177
177
  },
178
178
  });
179
179
  ```
@@ -184,13 +184,13 @@ socketPlugin({
184
184
 
185
185
  ```typescript
186
186
  // Subscribe to a single collection
187
- socket.emit("join-collection", "projects");
187
+ socket.emit("join-collection", "posts");
188
188
 
189
189
  // Subscribe to multiple collections
190
- socket.emit("subscribe", ["projects", "posts"]);
190
+ socket.emit("subscribe", ["posts", "users", "media"]);
191
191
 
192
192
  // Unsubscribe
193
- socket.emit("unsubscribe", ["projects"]);
193
+ socket.emit("unsubscribe", ["posts"]);
194
194
  ```
195
195
 
196
196
  ### Listening for Events
@@ -198,8 +198,8 @@ socket.emit("unsubscribe", ["projects"]);
198
198
  ```typescript
199
199
  // Listen to specific collection events
200
200
  socket.on("payload:event", (event) => {
201
- if (event.collection === "projects" && event.type === "update") {
202
- // Handle project update
201
+ if (event.collection === "posts" && event.type === "update") {
202
+ // Handle post update
203
203
  }
204
204
  });
205
205
 
@@ -211,6 +211,80 @@ socket.on("payload:event:all", (event) => {
211
211
 
212
212
  ## Advanced Usage
213
213
 
214
+ ### Custom Socket Event Handlers
215
+
216
+ You can register your own custom event handlers that will be attached to each authenticated socket.
217
+
218
+ **Simple inline handlers:**
219
+
220
+ ```typescript
221
+ socketPlugin({
222
+ onSocketConnection: (socket, io, payload) => {
223
+ // Custom event handler
224
+ socket.on("send-message", async (data) => {
225
+ const { roomId, message } = data;
226
+
227
+ // Broadcast to room
228
+ io.to(`room:${roomId}`).emit("new-message", {
229
+ user: socket.user,
230
+ message,
231
+ timestamp: new Date().toISOString(),
232
+ });
233
+ });
234
+
235
+ // Custom room management
236
+ socket.on("join-custom-room", (roomId) => {
237
+ socket.join(`room:${roomId}`);
238
+ socket.emit("joined-room", { roomId });
239
+ });
240
+
241
+ // Access Payload CMS from your handlers
242
+ socket.on("get-user-data", async () => {
243
+ const user = await payload.findByID({
244
+ collection: "users",
245
+ id: socket.user!.id as string,
246
+ });
247
+ socket.emit("user-data", user);
248
+ });
249
+ },
250
+ });
251
+ ```
252
+
253
+ **Organized in separate files (recommended):**
254
+
255
+ ```typescript
256
+ // Import from examples directory
257
+ import { projectHandlers } from "./examples/projectHandlers";
258
+ import { chatHandlers } from "./examples/chatHandlers";
259
+ import { notificationHandlers } from "./examples/notificationHandlers";
260
+
261
+ // Or import all at once
262
+ import {
263
+ projectHandlers,
264
+ chatHandlers,
265
+ notificationHandlers,
266
+ } from "./examples";
267
+
268
+ socketPlugin({
269
+ onSocketConnection: (socket, io, payload) => {
270
+ // Use one or more pre-built handlers
271
+ projectHandlers(socket, io, payload);
272
+ chatHandlers(socket, io, payload);
273
+ notificationHandlers(socket, io, payload);
274
+ },
275
+ });
276
+ ```
277
+
278
+ **See the [examples directory](./examples) for complete implementations** including:
279
+
280
+ | Handler | Features |
281
+ | ------------------------- | -------------------------------------------------------------------- |
282
+ | **Project Collaboration** | Join/leave rooms, permission checking, presence tracking, kick users |
283
+ | **Chat/Messaging** | Send messages, typing indicators, read receipts |
284
+ | **Notifications** | User notifications, broadcast announcements (admin only) |
285
+
286
+ Each example includes full client-side and server-side code with error handling and best practices.
287
+
214
288
  ### Custom Event Filtering
215
289
 
216
290
  ```typescript
@@ -271,6 +345,16 @@ socketPlugin({
271
345
  4. Authorization handlers determine which users receive the event
272
346
  5. Redis adapter ensures events sync across multiple server instances
273
347
 
348
+ ## Browser Compatibility
349
+
350
+ This plugin includes automatic browser-safe mocking for the Payload admin panel. When bundled for the browser (e.g., in the Payload admin UI), the plugin automatically uses a mock implementation that:
351
+
352
+ - Returns the config unchanged (no Socket.IO server initialization)
353
+ - Provides no-op functions for `initSocketIO()` and `SocketIOManager` methods
354
+ - Prevents server-side dependencies (Socket.IO, Redis) from being bundled in the browser
355
+
356
+ This is handled automatically via the `"browser"` field in `package.json`, so you don't need to configure anything special. The Socket.IO server only runs on the server side.
357
+
274
358
  ## Environment Variables
275
359
 
276
360
  ```bash
@@ -353,7 +437,6 @@ import type {
353
437
 
354
438
  ## Known Limitations
355
439
 
356
- - Only supports Payload CMS v2.x (v3.x support coming soon)
357
440
  - Authorization handlers are called for each connected user on every event
358
441
  - No built-in event replay or history mechanism
359
442
  - Redis is required for multi-instance deployments
@@ -0,0 +1,24 @@
1
+ import type { Config } from "payload/config";
2
+ /**
3
+ * Browser-safe mock for the Socket.IO plugin
4
+ * This file is used when bundling for the browser (e.g., Payload admin panel)
5
+ * The actual Socket.IO server only runs on the server side
6
+ */
7
+ export declare const socketPlugin: () => (config: Config) => Config;
8
+ /**
9
+ * Browser-safe mock for initSocketIO
10
+ * Does nothing in browser environment
11
+ */
12
+ export declare const initSocketIO: () => Promise<void>;
13
+ /**
14
+ * Browser-safe mock for SocketIOManager
15
+ * Returns a mock class that does nothing in browser environment
16
+ */
17
+ export declare class SocketIOManager {
18
+ constructor();
19
+ init(): Promise<null>;
20
+ emitEvent(): Promise<void>;
21
+ getIO(): null;
22
+ close(): Promise<void>;
23
+ }
24
+ export * from "./types";
@@ -0,0 +1,61 @@
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.SocketIOManager = exports.initSocketIO = exports.socketPlugin = void 0;
18
+ /**
19
+ * Browser-safe mock for the Socket.IO plugin
20
+ * This file is used when bundling for the browser (e.g., Payload admin panel)
21
+ * The actual Socket.IO server only runs on the server side
22
+ */
23
+ const socketPlugin = () => (config) => {
24
+ // Return config unchanged - Socket.IO server is server-side only
25
+ return config;
26
+ };
27
+ exports.socketPlugin = socketPlugin;
28
+ /**
29
+ * Browser-safe mock for initSocketIO
30
+ * Does nothing in browser environment
31
+ */
32
+ const initSocketIO = async () => {
33
+ // No-op in browser
34
+ console.warn("initSocketIO called in browser environment - this is a no-op");
35
+ };
36
+ exports.initSocketIO = initSocketIO;
37
+ /**
38
+ * Browser-safe mock for SocketIOManager
39
+ * Returns a mock class that does nothing in browser environment
40
+ */
41
+ class SocketIOManager {
42
+ constructor() {
43
+ // No-op in browser
44
+ }
45
+ async init() {
46
+ console.warn("SocketIOManager.init called in browser environment - this is a no-op");
47
+ return null;
48
+ }
49
+ async emitEvent() {
50
+ // No-op in browser
51
+ }
52
+ getIO() {
53
+ return null;
54
+ }
55
+ async close() {
56
+ // No-op in browser
57
+ }
58
+ }
59
+ exports.SocketIOManager = SocketIOManager;
60
+ // Export types (these are safe for browser)
61
+ __exportStar(require("./types"), exports);
@@ -123,7 +123,7 @@ class SocketIOManager {
123
123
  * Setup connection event handlers
124
124
  */
125
125
  setupConnectionHandlers() {
126
- this.io.on("connection", (socket) => {
126
+ this.io.on("connection", async (socket) => {
127
127
  payload_1.default.logger.info(`Client connected: ${socket.id}, User: ${socket.user?.email || socket.user?.id}`);
128
128
  // Allow clients to subscribe to specific collections
129
129
  socket.on("subscribe", (collections) => {
@@ -135,7 +135,7 @@ class SocketIOManager {
135
135
  payload_1.default.logger.info(`Client ${socket.id} subscribed to collection: ${collection}`);
136
136
  });
137
137
  });
138
- // // Allow clients to unsubscribe from collections
138
+ // Allow clients to unsubscribe from collections
139
139
  socket.on("unsubscribe", (collections) => {
140
140
  const collectionList = Array.isArray(collections)
141
141
  ? collections
@@ -145,169 +145,24 @@ class SocketIOManager {
145
145
  payload_1.default.logger.info(`Client ${socket.id} unsubscribed from collection: ${collection}`);
146
146
  });
147
147
  });
148
- // Allow clients to join collection rooms to receive update events
148
+ // Allow clients to join collection rooms (alias for subscribe)
149
149
  socket.on("join-collection", (collection) => {
150
150
  const roomName = `collection:${collection}`;
151
151
  socket.join(roomName);
152
152
  payload_1.default.logger.info(`Client ${socket.id} (${socket.user?.email}) joined collection room: ${roomName}`);
153
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);
154
+ // Handle disconnection
155
+ socket.on("disconnect", () => {
156
+ payload_1.default.logger.info(`Client disconnected: ${socket.id}, User: ${socket.user?.email || socket.user?.id}`);
237
157
  });
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
158
+ if (this.options.onSocketConnection) {
245
159
  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
- }
160
+ await this.options.onSocketConnection(socket, this.io, payload_1.default);
289
161
  }
290
162
  catch (error) {
291
- payload_1.default.logger.error("Error kicking user:", error);
292
- socket.emit("kick-error", {
293
- message: "Failed to kick user",
294
- });
163
+ payload_1.default.logger.error(`Error in custom socket connection handler: ${error}`);
295
164
  }
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
- });
165
+ }
311
166
  });
312
167
  }
313
168
  /**
package/dist/types.d.ts CHANGED
@@ -110,4 +110,23 @@ export interface RealtimeEventsPluginOptions {
110
110
  * Custom event transformer
111
111
  */
112
112
  transformEvent?: (event: RealtimeEventPayload) => RealtimeEventPayload;
113
+ /**
114
+ * Custom socket event handlers
115
+ * Register your own event handlers that will be attached to each authenticated socket
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * onSocketConnection: (socket, io, payload) => {
120
+ * socket.on('custom-event', (data) => {
121
+ * // Handle custom event
122
+ * socket.emit('custom-response', { success: true });
123
+ * });
124
+ *
125
+ * socket.on('join-room', (roomId) => {
126
+ * socket.join(`custom:${roomId}`);
127
+ * });
128
+ * }
129
+ * ```
130
+ */
131
+ onSocketConnection?: (socket: AuthenticatedSocket, io: any, payload: any) => void | Promise<void>;
113
132
  }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "payload-socket-plugin",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "Real-time Socket.IO plugin for Payload CMS with Redis support",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
- "browser": "dist/mock.js",
7
+ "browser": "dist/browser.js",
8
8
  "files": [
9
9
  "dist",
10
10
  "README.md"
@@ -30,9 +30,6 @@
30
30
  "engines": {
31
31
  "node": ">=20.0.0"
32
32
  },
33
- "peerDependencies": {
34
- "payload": "^2.0.0"
35
- },
36
33
  "dependencies": {
37
34
  "@socket.io/redis-adapter": "^8.0.0",
38
35
  "ioredis": "^5.3.0",
@@ -42,7 +39,6 @@
42
39
  "devDependencies": {
43
40
  "@types/jsonwebtoken": "^9.0.0",
44
41
  "@types/node": "^20.0.0",
45
- "payload": "^2.0.0",
46
42
  "typescript": "^5.0.0"
47
43
  },
48
44
  "repository": {
package/dist/mock.d.ts DELETED
@@ -1,13 +0,0 @@
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 DELETED
@@ -1,19 +0,0 @@
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;