Skip to content

Latest commit

 

History

History
632 lines (487 loc) · 14.5 KB

File metadata and controls

632 lines (487 loc) · 14.5 KB

Hooks Examples

Learn how to use lifecycle hooks for initialization, adapters, and custom middleware.


Overview

Hooks are similar to Strapi model lifecycles. They are functions called at specific points in the Socket.IO plugin lifecycle.


Init Hook

The init hook is triggered immediately after the Socket.IO server instance is constructed. It's perfect for:

  • Adding server adapters (Redis, MongoDB)
  • Registering middleware
  • Configuring namespaces
  • Setting up custom logic

Signature

init(context: {
  strapi: Strapi;
  io: SocketIOInstance;
}): void | Promise<void>

Redis Adapter

Scale your Socket.IO server across multiple instances using Redis:

const { createClient } = require('redis');
const { createAdapter } = require('@socket.io/redis-adapter');

module.exports = ({ env }) => ({
  io: {
    enabled: true,
    config: {
      contentTypes: ['api::article.article'],
      hooks: {
        async init({ strapi, io }) {
          const pubClient = createClient({
            url: env('REDIS_URL', 'redis://localhost:6379')
          });
          
          const subClient = pubClient.duplicate();
          
          await Promise.all([
            pubClient.connect(),
            subClient.connect()
          ]);
          
          io.server.adapter(createAdapter(pubClient, subClient));
          
          strapi.log.info('[io] ✅ Redis adapter initialized');
          
          // Handle errors
          pubClient.on('error', (err) => {
            strapi.log.error('[io] Redis pub client error:', err);
          });
          
          subClient.on('error', (err) => {
            strapi.log.error('[io] Redis sub client error:', err);
          });
        }
      }
    }
  }
});

Benefits:

  • ✅ Horizontal scaling across multiple servers
  • ✅ Load balancing
  • ✅ Session persistence
  • ✅ High availability

MongoDB Adapter

Use MongoDB as the adapter for distributed Socket.IO:

const { MongoClient } = require('mongodb');
const { createAdapter } = require('@socket.io/mongo-adapter');

module.exports = ({ env }) => ({
  io: {
    enabled: true,
    config: {
      hooks: {
        async init({ strapi, io }) {
          const mongoClient = new MongoClient(env('MONGO_URL'));
          await mongoClient.connect();
          
          const mongoCollection = mongoClient
            .db('mydb')
            .collection('socket.io-adapter-events');
          
          io.server.adapter(createAdapter(mongoCollection, {
            addCreatedAtField: true
          }));
          
          strapi.log.info('[io] ✅ MongoDB adapter initialized');
        }
      }
    }
  }
});

Custom Middleware

Add authentication, logging, or rate limiting middleware:

Authentication Middleware

module.exports = {
  io: {
    enabled: true,
    config: {
      hooks: {
        init({ strapi, io }) {
          io.server.use(async (socket, next) => {
            try {
              const token = socket.handshake.auth.token;
              
              if (!token) {
                return next(new Error('Authentication required'));
              }
              
              // Verify JWT token
              const user = await strapi.plugins['users-permissions']
                .services.jwt.verify(token);
              
              if (!user) {
                return next(new Error('Invalid token'));
              }
              
              // Attach user to socket
              socket.data.user = user;
              
              next();
            } catch (error) {
              next(new Error('Authentication failed'));
            }
          });
          
          strapi.log.info('[io] Authentication middleware registered');
        }
      }
    }
  }
};

Logging Middleware

hooks: {
  init({ strapi, io }) {
    io.server.use((socket, next) => {
      strapi.log.info('[io] Connection attempt:', {
        socketId: socket.id,
        ip: socket.handshake.address,
        userAgent: socket.handshake.headers['user-agent'],
        timestamp: new Date().toISOString()
      });
      
      // Add connection timestamp
      socket.data.connectedAt = Date.now();
      
      next();
    });
  }
}

IP Whitelisting Middleware

hooks: {
  init({ strapi, io }) {
    const allowedIPs = [
      '127.0.0.1',
      '::1',
      '192.168.1.0/24'
    ];
    
    io.server.use((socket, next) => {
      const clientIP = socket.handshake.address;
      
      if (!isIPAllowed(clientIP, allowedIPs)) {
        strapi.log.warn(`[io] Blocked connection from ${clientIP}`);
        return next(new Error('IP not whitelisted'));
      }
      
      next();
    });
  }
}

Rate Limiting Middleware

const rateLimit = require('socket.io-rate-limit');

hooks: {
  init({ strapi, io }) {
    io.server.use(rateLimit({
      tokensPerInterval: 100,
      interval: 60000, // 100 requests per minute
      fireImmediately: true,
      onLimitReached: (socket) => {
        strapi.log.warn(`[io] Rate limit exceeded: ${socket.id}`);
        socket.emit('error', {
          message: 'Too many requests. Please slow down.'
        });
      }
    }));
    
    strapi.log.info('[io] Rate limiting enabled');
  }
}

Namespaces

Create separate communication channels:

hooks: {
  init({ strapi, io }) {
    // Admin namespace
    const adminNamespace = io.server.of('/admin');
    
    adminNamespace.use(async (socket, next) => {
      // Admin-only authentication
      const token = socket.handshake.auth.token;
      const user = await verifyAdminToken(token);
      
      if (!user || user.role.type !== 'admin') {
        return next(new Error('Admin access required'));
      }
      
      socket.data.user = user;
      next();
    });
    
    adminNamespace.on('connection', (socket) => {
      strapi.log.info(`[io] Admin connected: ${socket.data.user.username}`);
      
      socket.on('broadcast', (data) => {
        // Broadcast to all users
        io.server.emit('admin:announcement', data);
      });
    });
    
    // Chat namespace
    const chatNamespace = io.server.of('/chat');
    
    chatNamespace.on('connection', (socket) => {
      strapi.log.info('[io] Chat user connected');
      
      socket.on('message', (data) => {
        chatNamespace.emit('message', data);
      });
    });
    
    strapi.log.info('[io] Namespaces configured: /admin, /chat');
  }
}

Client usage:

// Connect to admin namespace
const adminSocket = io('http://localhost:1337/admin', {
  auth: { token: adminToken }
});

// Connect to chat namespace
const chatSocket = io('http://localhost:1337/chat', {
  auth: { token: userToken }
});

Monitoring & Analytics

Connection Tracking

hooks: {
  init({ strapi, io }) {
    const connections = new Map();
    
    io.server.on('connection', (socket) => {
      const connectionInfo = {
        socketId: socket.id,
        ip: socket.handshake.address,
        userAgent: socket.handshake.headers['user-agent'],
        connectedAt: new Date(),
        userId: socket.data.user?.id
      };
      
      connections.set(socket.id, connectionInfo);
      
      // Log to database
      strapi.entityService.create('api::connection-log.connection-log', {
        data: connectionInfo
      });
      
      socket.on('disconnect', () => {
        const info = connections.get(socket.id);
        const duration = Date.now() - info.connectedAt.getTime();
        
        strapi.log.info(`[io] Session ended: ${socket.id} (${duration}ms)`);
        
        // Update log
        strapi.entityService.update('api::connection-log.connection-log', {
          where: { socketId: socket.id },
          data: {
            disconnectedAt: new Date(),
            duration
          }
        });
        
        connections.delete(socket.id);
      });
    });
    
    // Periodic stats logging
    setInterval(() => {
      strapi.log.info(`[io] Active connections: ${connections.size}`);
    }, 60000); // Every minute
  }
}

Event Analytics

hooks: {
  init({ strapi, io }) {
    const eventStats = new Map();
    
    // Intercept all events
    io.server.use((socket, next) => {
      socket.use((packet, next) => {
        const [eventName] = packet;
        
        // Track event
        const count = eventStats.get(eventName) || 0;
        eventStats.set(eventName, count + 1);
        
        next();
      });
      
      next();
    });
    
    // Report stats every 5 minutes
    setInterval(() => {
      const stats = Array.from(eventStats.entries())
        .map(([event, count]) => ({ event, count }))
        .sort((a, b) => b.count - a.count);
      
      strapi.log.info('[io] Event statistics:', stats);
      
      // Reset stats
      eventStats.clear();
    }, 300000);
  }
}

Auto-Join Rooms

Automatically join users to rooms based on their role or properties:

hooks: {
  init({ strapi, io }) {
    io.server.on('connection', async (socket) => {
      if (!socket.data.user) return;
      
      const user = socket.data.user;
      
      // Join user-specific room
      socket.join(`user-${user.id}`);
      
      // Join role-based room
      socket.join(`role-${user.role.type}`);
      
      // Join premium room
      if (user.isPremium) {
        socket.join('premium');
      }
      
      // Join location-based rooms
      if (user.country) {
        socket.join(`country-${user.country}`);
      }
      
      strapi.log.info(`[io] User ${user.username} joined rooms:`, 
        Array.from(socket.rooms)
      );
    });
  }
}

Graceful Shutdown

Handle server shutdown gracefully:

hooks: {
  init({ strapi, io }) {
    const shutdown = async (signal) => {
      strapi.log.info(`[io] Received ${signal}, closing connections...`);
      
      // Notify all clients
      io.server.emit('server:shutdown', {
        message: 'Server is restarting',
        reconnectIn: 5000
      });
      
      // Wait for messages to be sent
      await new Promise(resolve => setTimeout(resolve, 1000));
      
      // Close all connections
      const sockets = await io.server.fetchSockets();
      sockets.forEach(socket => {
        socket.disconnect(true);
      });
      
      strapi.log.info(`[io] Closed ${sockets.length} connections`);
    };
    
    process.on('SIGTERM', () => shutdown('SIGTERM'));
    process.on('SIGINT', () => shutdown('SIGINT'));
  }
}

Custom Room Logic

Implement complex room joining logic:

hooks: {
  init({ strapi, io }) {
    io.server.on('connection', (socket) => {
      socket.on('join-channel', async ({ channelId, password }) => {
        try {
          // Fetch channel
          const channel = await strapi.entityService.findOne(
            'api::channel.channel',
            channelId
          );
          
          if (!channel) {
            socket.emit('error', { message: 'Channel not found' });
            return;
          }
          
          // Check password if private
          if (channel.isPrivate && channel.password !== password) {
            socket.emit('error', { message: 'Invalid password' });
            return;
          }
          
          // Check member limit
          const roomSize = (await io.server.in(`channel-${channelId}`).fetchSockets()).length;
          if (channel.maxMembers && roomSize >= channel.maxMembers) {
            socket.emit('error', { message: 'Channel is full' });
            return;
          }
          
          // Join room
          socket.join(`channel-${channelId}`);
          
          // Notify others
          socket.to(`channel-${channelId}`).emit('user:joined', {
            userId: socket.data.user?.id,
            username: socket.data.user?.username
          });
          
          socket.emit('channel:joined', {
            channelId,
            members: roomSize + 1
          });
        } catch (error) {
          strapi.log.error('[io] Join channel error:', error);
          socket.emit('error', { message: 'Failed to join channel' });
        }
      });
    });
  }
}

Helper Functions Integration

Add custom helper functions to the IO instance:

hooks: {
  init({ strapi, io }) {
    // Add custom helper
    io.kickUser = async (userId, reason) => {
      const stats = strapi.plugin('io').service('monitoring').getConnectionStats();
      const userSocket = stats.sockets.find(s => s.user?.id === userId);
      
      if (userSocket) {
        io.server.to(userSocket.id).emit('kicked', { reason });
        io.disconnectSocket(userSocket.id, reason);
        return true;
      }
      
      return false;
    };
    
    // Add broadcast to role
    io.broadcastToRole = (roleName, event, data) => {
      io.server.to(`role-${roleName}`).emit(event, data);
    };
    
    strapi.log.info('[io] Custom helpers registered');
  }
}

Usage:

// Kick abusive user
await strapi.$io.kickUser(123, 'Violation of terms');

// Broadcast to all admins
strapi.$io.broadcastToRole('admin', 'system:alert', {
  level: 'critical',
  message: 'Database backup required'
});

Testing

// test/hooks.test.js
describe('Socket.IO Hooks', () => {
  test('should have Redis adapter', async () => {
    const adapter = strapi.$io.server.adapter;
    expect(adapter.constructor.name).toBe('RedisAdapter');
  });
  
  test('should have custom helpers', () => {
    expect(typeof strapi.$io.kickUser).toBe('function');
    expect(typeof strapi.$io.broadcastToRole).toBe('function');
  });
  
  test('should auto-join user room', (done) => {
    const socket = io('http://localhost:1337', {
      auth: { token: userToken }
    });
    
    socket.on('connect', () => {
      // Verify user joined their room
      socket.emit('get-rooms', (rooms) => {
        expect(rooms).toContain(`user-${userId}`);
        done();
      });
    });
  });
});

Best Practices

✅ Do

  • Use adapters for production scaling
  • Implement rate limiting
  • Log important events
  • Handle errors gracefully
  • Clean up resources on disconnect
  • Use TypeScript for complex logic

❌ Don't

  • Block the event loop
  • Store large objects in socket.data
  • Forget to handle adapter errors
  • Skip authentication middleware
  • Leave connections open unnecessarily

See Also