payload-socket-plugin 1.0.0 → 1.1.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/README.md CHANGED
@@ -144,6 +144,7 @@ socket.on("payload:event", (event) => {
144
144
  | `authorize` | `object` | - | Per-collection authorization handlers |
145
145
  | `shouldEmit` | `function` | - | Filter function to determine if event should be emitted |
146
146
  | `transformEvent` | `function` | - | Transform events before emitting |
147
+ | `onSocketConnection` | `function` | - | Custom event handlers for each socket connection |
147
148
 
148
149
  ## Authorization
149
150
 
@@ -211,6 +212,80 @@ socket.on("payload:event:all", (event) => {
211
212
 
212
213
  ## Advanced Usage
213
214
 
215
+ ### Custom Socket Event Handlers
216
+
217
+ You can register your own custom event handlers that will be attached to each authenticated socket.
218
+
219
+ **Simple inline handlers:**
220
+
221
+ ```typescript
222
+ socketPlugin({
223
+ onSocketConnection: (socket, io, payload) => {
224
+ // Custom event handler
225
+ socket.on("send-message", async (data) => {
226
+ const { roomId, message } = data;
227
+
228
+ // Broadcast to room
229
+ io.to(`room:${roomId}`).emit("new-message", {
230
+ user: socket.user,
231
+ message,
232
+ timestamp: new Date().toISOString(),
233
+ });
234
+ });
235
+
236
+ // Custom room management
237
+ socket.on("join-custom-room", (roomId) => {
238
+ socket.join(`room:${roomId}`);
239
+ socket.emit("joined-room", { roomId });
240
+ });
241
+
242
+ // Access Payload CMS from your handlers
243
+ socket.on("get-user-data", async () => {
244
+ const user = await payload.findByID({
245
+ collection: "users",
246
+ id: socket.user!.id as string,
247
+ });
248
+ socket.emit("user-data", user);
249
+ });
250
+ },
251
+ });
252
+ ```
253
+
254
+ **Organized in separate files (recommended):**
255
+
256
+ ```typescript
257
+ // Import from examples directory
258
+ import { projectHandlers } from "./examples/projectHandlers";
259
+ import { chatHandlers } from "./examples/chatHandlers";
260
+ import { notificationHandlers } from "./examples/notificationHandlers";
261
+
262
+ // Or import all at once
263
+ import {
264
+ projectHandlers,
265
+ chatHandlers,
266
+ notificationHandlers,
267
+ } from "./examples";
268
+
269
+ socketPlugin({
270
+ onSocketConnection: (socket, io, payload) => {
271
+ // Use one or more pre-built handlers
272
+ projectHandlers(socket, io, payload);
273
+ chatHandlers(socket, io, payload);
274
+ notificationHandlers(socket, io, payload);
275
+ },
276
+ });
277
+ ```
278
+
279
+ **See the [examples directory](./examples) for complete implementations** including:
280
+
281
+ | Handler | Features |
282
+ | ------------------------- | -------------------------------------------------------------------- |
283
+ | **Project Collaboration** | Join/leave rooms, permission checking, presence tracking, kick users |
284
+ | **Chat/Messaging** | Send messages, typing indicators, read receipts |
285
+ | **Notifications** | User notifications, broadcast announcements (admin only) |
286
+
287
+ Each example includes full client-side and server-side code with error handling and best practices.
288
+
214
289
  ### Custom Event Filtering
215
290
 
216
291
  ```typescript
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "payload-socket-plugin",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
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",