node.jsherokuws

Websocket sever deployed to Heroku does not initialize so client recieves a 404


My project requires that I send information to the front-end concerning the status of a transaction so the client will be notified. I began to do this using Server Sent Events and I had issues with firefox be unstable though messages came through. My project lead asked me to use websockets since all browsers support it. I followed Heroku's directions on how to implement it after I got it working on my local development server. I noticed the client return blocked and in Heroku logs its says [router]: at=info method=GET path="/api/v1/ws" host=dreamjobs-api-2de3c2827093.herokuapp.com request_id=dd486341-52db-4493-837d-0791fa443127 fwd="154.160.14.153" dyno=web.1 connect=0ms service=1ms status=404 bytes=964 protocol=https I was thinking there has to be some configuration for Heroku to enable that communication so I searched online and found that I need to log in to Heroku CLI and send this heroku features:enable http-session-affinity -a your-app-name. I did just that but the websocket server is still not running in Heroku. Please analyze my code because I believe I did all I was directed to on Heroku's instruction on implementing this feature. Any attempts will be appreciated.

import express, { Request, Response } from 'express';
import cors from 'cors';
import bodyParser from 'body-parser';
import helmet from 'helmet';
import prisma from './lib/prisma';
import { Socket } from 'net';
import { createServer, IncomingMessage } from 'http';
import WebSocket, { Server as WebSocketServer } from 'ws';
//other imports for routes...

interface ExtendedWebSocket extends WebSocket {
  isAlive: boolean;
}

const port = process.env.PORT || 8080;
const app = express();
const server = createServer(app);

async function main() {
  // app.use(express.json());

  app.use(
    cors({
      origin: function (origin, callback) {
        const allowedOrigins = ['https://www.myfrontend.com', 'https://admin.myfrontend.com'];
        if (!origin || allowedOrigins.includes(origin) || origin.startsWith('ws://') || origin.startsWith('wss://')) {
          callback(null, true);
        } else {
          callback(new Error('Not allowed by CORS'));
        }
      },
      optionsSuccessStatus: 200,
    }),
  );

  app.use(helmet());
  app.use(bodyParser.urlencoded({ limit: '100mb', extended: false }));
  app.use(bodyParser.json({ limit: '100mb' }));

  // Create a WebSocket server
  // const wss: WebSocketServer = new WebSocketServer({ port: 1280 });
  const wss: WebSocketServer = new WebSocketServer({ server });

  // A map to store connected clients
  const clients: Map<string, ExtendedWebSocket> = new Map();

  wss.on('connection', (ws: ExtendedWebSocket) => {
    //Error handling
    ws.on('error', (error: Error) => {
      console.log('error', error);
    });
    // Generate a unique ID for the client and add it to the map
    const clientId = Date.now();
    clients.set(clientId.toString(), ws);

    console.log('Client connected:', clientId);

    // Implement ping-pong to keep connection alive
    ws.isAlive = true;
    ws.on('pong', () => {
      ws.isAlive = true;
    });
    // Handle incoming messages from the client
    ws.on('message', (message: string) => {
      console.log('message', message);
    });

    // Send a message to the client
    const systemMsg: { id: string; message: string } = { id: clientId.toString(), message: 'Connected' };
    ws.send(JSON.stringify(systemMsg));

    // Clean up when the client disconnects
    ws.on('close', () => {
      console.log('Client disconnected:', clientId);
      clients.delete(clientId.toString());
    });
  });
  // Implement ping-pong interval
  const interval = setInterval(() => {
    wss.clients.forEach((ws: WebSocket) => {
      const extendedWs = ws as ExtendedWebSocket;
      if (extendedWs.isAlive === false) return ws.terminate();
      extendedWs.isAlive = false;
      ws.ping(() => {
        ws.pong();
      });
    });
  }, 30000);

  wss.on('close', () => {
    clearInterval(interval);
  });

  // Upgrade HTTP server to WebSocket server
  server.on('upgrade', (request: IncomingMessage, socket: Socket, head: Buffer) => {
    socket.on('error', (error) => {
      console.error('Socket error:', error);
    });
    if (request.headers.upgrade === 'websocket' && request.url === 'api/v1/ws') {
      wss.handleUpgrade(request, socket, head, (ws: WebSocket) => {
        socket.removeListener('error', (error) => console.log(`error ${error}`));
        wss.emit('connection', ws, request);
      });
    } else {
      socket.destroy(); // Not a WebSocket handshake
    }
  });

  //event webhook
  app.post('/api/v1/payment/momo-event-webhook', async (req: Request, res: Response) => {
    const event = req.body;

    // Relay the event to all connected clients
    for (const [clientId, clientWs] of clients.entries()) {
      console.log('Sending event to client', clientId);
      clientWs.send(JSON.stringify({ event, clientId }));
    }

    res.status(200).json({ success: true, message: 'Event received' });
  });

  // Register API routes
  //....

  // Specific CORS configuration for the webhook route
  const webhookCorsOptions = {
    origin: '*', // Allow from all origins, or specify the exact origin like 'https://payment-gateway.com'
    methods: ['GET', 'POST'], //  Allow POST and GET requests
    allowedHeaders: ['Content-Type'],
  };

  // Webhook route
  app.use('/api/v1/payment/momo-response-webhook', cors(webhookCorsOptions), (req: Request, res: Response) => {
    // Handle the webhook request
    console.log('Webhook received:', req.body);
    res.status(200).json({ success: true, message: 'Webhook received' });
  });

  // Catch unregistered routes
  app.all('*', (req: Request, res: Response) => {
    res.status(404).json({ error: `Route ${req.originalUrl} not found` });
  });

  app.listen(port, () => {
    console.log(`Server is listening on port ${port}`);
  });
}

main()
  .then(async () => {
    await prisma.$connect();
  })
  .catch(async (e) => {
    console.error(e);
    await prisma.$disconnect();
    process.exit(1);
  });

Steps to reproduce

  1. create index.ts and setup a simple typescript compilation to JS
  2. Copy the code over and install wscat globally
  3. test connection with wscat -c ws://localhost:8080/api/v1/ws

Solution

  • It was a post I saw on this platform that gave me an idea on how to approach this situation. So in 4 steps this issue was solved

    step 1. Create an interface to extend Request object

    interface WebSocketServerRequest extends Request {
      wss?: WebSocketServer;
    }
    

    step 2. Create a middleware to use the extended Request and place websocket instance in to request object

    const addWebSocketServer = (wss: WebSocketServer) => {
        return (req: WebSocketServerRequest, res: Response, next: NextFunction) => {
          req.wss = wss;
          next();
        };
      };
    

    step 3. Use the middleware

    app.use(addWebSocketServer(wss));
    

    step 4.Create a route handler for websocket and in it upgrade the server to use websocket protocol

    app.get('/api/v1/ws', (req: WebSocketServerRequest, res: Response) => {
        const wss = req.wss;
        if (wss) {
          //Request websocket upgrade
          wss.handleUpgrade(req, req.socket, Buffer.alloc(0), (ws) => {
            wss.emit('connection', ws, req);
          });
          // Handle the WebSocket request
          console.log('WebSocket server available');
        } else {
          res.status(400).json({ error: 'WebSocket server not available' });
        }
      });
    

    This has resolved the issue and I can connect to websocket server on Heroku but there is an issue of disconnection after about 40 sec after the connection is made. Error says Invalid WebSocket frame: FIN must be set I have looked online but no results so I have posted it in the github page of ws