import { createServer } from "http";
import { parse } from "url";
import next from "next";
import { Server } from "socket.io";
import jwt from "jsonwebtoken";
import type {
ServerToClientEvents,
ClientToServerEvents,
InterServerEvents,
SocketData,
} from "./src/types/socket";
import { chatMessageSchema } from "@/lib/validation";
// Determine if we are running in development mode.
// Next.js behaves differently in dev (hot reload, unminified errors) vs production.
const dev = process.env.NODE_ENV !== "production";
// Hostname the server binds to. Fixed to localhost for local development.
const hostname = "localhost";
// Port the server listens on, read from environment variable if provided,
// otherwise defaults to 3000.
const port = parseInt(process.env.PORT || "3000", 10);
// Create the Next.js application instance.
// This is the same app instance that would normally run behind `next dev`,
// but here we are wrapping it manually inside our own server.
const app = next({ dev, hostname, port });
// This function knows how to handle any incoming request the way Next.js
// normally would (routing, rendering pages, API routes, etc.).
const handle = app.getRequestHandler();
// Wait for Next.js to fully prepare itself (compile routes, load config)
// before starting our custom server.
app.prepare().then(() => {
// Create one plain Node.js HTTP server.
// Every incoming HTTP request passes through this function first.
const httpServer = createServer((req, res) => {
// A lightweight health check endpoint.
// Hosting platforms can hit this URL to confirm the server is alive,
// without going through the full Next.js rendering pipeline.
if (req.url === "/health") {
res.writeHead(200);
res.end("OK");
return;
}
// Parse the incoming request URL into a structured object
// (pathname, query params, etc.), which Next.js needs internally.
const parsedUrl = parse(req.url!, true);
// Hand off the request to Next.js so it can serve pages,
// API routes, static assets, and everything else normally.
handle(req, res, parsedUrl);
});
// Attach a Socket.IO server to the same HTTP server created above.
// This means Next.js and Socket.IO share one process and one port,
// instead of running as two separate servers.
//
// The four generic types passed here give us full TypeScript safety:
// - ClientToServerEvents: events the client can send to the server
// - ServerToClientEvents: events the server can send to the client
// - InterServerEvents: events used between multiple server instances
// - SocketData: custom data we can attach to each socket connection
const io = new Server<
ClientToServerEvents,
ServerToClientEvents,
InterServerEvents,
SocketData
>(httpServer, {
// Custom path for Socket.IO's own connection handling,
// separate from normal Next.js routes.
path: "/api/socket",
// Enables connection state recovery: if a client disconnects
// briefly (e.g. network drop) and reconnects within this window,
// Socket.IO will try to restore its rooms, data, and missed events.
connectionStateRecovery: {
maxDisconnectionDuration: 2 * 60 * 1000, // 2 minutes, in milliseconds
},
});
// Authentication middleware.
// This runs once for every socket, BEFORE the "connection" event fires.
// If it does not call next() with no arguments, the connection is rejected.
io.use((socket, next) => {
// Read the token the client sent during the handshake,
// via the `auth` option when creating the socket on the client side.
const token = socket.handshake.auth?.token;
// No token at all means the client never attempted to authenticate.
// Reject the connection immediately with a clear error message.
if (!token) {
return next(new Error("Authentication error: token required"));
}
try {
// Verify the token's signature and expiry using our secret key.
// If the token is invalid or expired, this throws an error,
// which is caught below.
const decoded = jwt.verify(
token,
process.env.JWT_SECRET as string
) as { userId: string; username: string };
// Store the verified user information directly on the socket.
// This data persists for the entire lifetime of this connection,
// so every event handler below can trust it without re-checking.
socket.data.userId = decoded.userId;
socket.data.username = decoded.username;
// Allow the connection to proceed.
next();
} catch {
// Token verification failed (invalid signature, expired, malformed).
// Reject the connection with an authentication error.
next(new Error("Authentication error: invalid token"));
}
});
// Simple in-memory counter tracking how many clients are currently connected.
// This resets to 0 every time the server restarts, since it lives in memory only.
let onlineCount = 0;
// This block runs every time a new client successfully connects
// (i.e. after passing the authentication middleware above).
io.on("connection", (socket) => {
// A new client connected: increase the count and broadcast
// the updated number to every connected client, including this one.
onlineCount++;
io.emit("onlineCount", onlineCount);
// Client wants to join a specific chat room.
// socket.join() is a server-side operation; the client has no
// direct visibility into which rooms it belongs to.
socket.on("joinRoom", (roomName) => {
socket.join(roomName);
});
// Client wants to leave a specific chat room.
socket.on("leaveRoom", (roomName) => {
socket.leave(roomName);
});
// Client sent a chat message.
// Marked async in case future logic here needs to await something
// (e.g. saving to a database), though nothing async happens yet.
socket.on("chatMessage", async (data) => {
try {
// Validate the incoming data against our Zod schema.
// This protects against malformed or malicious payloads,
// since TypeScript types alone are erased at runtime.
const result = chatMessageSchema.safeParse(data);
// If validation fails, tell only this client about the
// problem and stop processing this message entirely.
if (!result.success) {
socket.emit("errorMessage", "Invalid message");
return;
}
// Broadcast the validated message to everyone in the room,
// including the sender. This keeps the server as the single
// source of truth, so the sender does not need separate
// local-only message handling.
io.to(result.data.room).emit("newMessage", {
id: `${socket.id}-${Date.now()}`, // simple unique message id
text: result.data.text,
// Sender name comes from the authenticated socket data,
// NOT from the client payload, so nobody can impersonate
// another user by sending a fake "sender" field.
sender: socket.data.username,
room: result.data.room,
timestamp: Date.now(),
});
} catch (err) {
// Catch any unexpected runtime error (e.g. something failing
// unexpectedly inside this handler) so it does not crash
// the server process, and inform the client something went wrong.
console.error("chatMessage error:", err);
socket.emit("errorMessage", "Something went wrong");
}
});
// Client is typing (or stopped typing) in a specific room.
// socket.to(...) sends this to everyone in that room EXCEPT
// the sender, since a user does not need to see their own
// typing indicator.
socket.on("typing", (data) => {
socket.to(data.room).emit("userTyping", {
username: socket.data.username,
isTyping: data.isTyping,
});
});
// Fires when this client disconnects, for any reason
// (tab closed, network drop, server restart, etc.).
socket.on("disconnect", () => {
// Decrease the online count and broadcast the updated
// number to everyone still connected.
onlineCount--;
io.emit("onlineCount", onlineCount);
});
});
// Start listening for incoming connections on the configured port.
// This single call starts both Next.js page/API handling and
// Socket.IO real-time handling together, since they share this
// same underlying HTTP server.
httpServer.listen(port, () => {
console.log(`Server ready on http://${hostname}:${port}`);
});
});