Build a complete real-time chat application with WSX
import { createExpressWSXServer } from "@wsx-sh/express";
import express from "express";
import path from "path";
const wsx = createExpressWSXServer();
const app = wsx.getApp();
// In-memory storage (use a database in production)
const messages = [];
const users = new Map();
const typingUsers = new Map();
app.use(express.static(path.join(__dirname, "public")));
app.use(express.json());
// User login
wsx.on("login", async (request, connection) => {
const { username } = request.data;
if (!username) {
return {
id: request.id,
target: "#error",
html: '<div class="error">Username is required</div>',
};
}
// Store user info
users.set(connection.id, {
id: connection.id,
username,
joinedAt: new Date(),
});
connection.sessionData = { username };
// Broadcast user joined
wsx.broadcast("#user-list", generateUserList());
wsx.broadcast(
"#messages",
`
<div class="system-message">
<span class="timestamp">${new Date().toLocaleTimeString()}</span>
${username} joined the chat
</div>
`,
"afterbegin"
);
return {
id: request.id,
target: "#login-form",
html: "",
swap: "delete",
oob: [
{
target: "#chat-container",
html: generateChatInterface(username),
swap: "innerHTML",
},
{
target: "#message-history",
html: generateMessageHistory(),
swap: "innerHTML",
},
],
};
});
// Handle disconnection
wsx.on("disconnect", async (request, connection) => {
const user = users.get(connection.id);
if (user) {
users.delete(connection.id);
typingUsers.delete(connection.id);
// Broadcast user left
wsx.broadcast("#user-list", generateUserList());
wsx.broadcast(
"#messages",
`
<div class="system-message">
<span class="timestamp">${new Date().toLocaleTimeString()}</span>
${user.username} left the chat
</div>
`,
"afterbegin"
);
}
});
// Send message
wsx.on("send-message", async (request, connection) => {
const { message } = request.data;
const user = users.get(connection.id);
if (!user || !message.trim()) {
return;
}
const messageObj = {
id: Date.now(),
username: user.username,
message: message.trim(),
timestamp: new Date(),
};
messages.push(messageObj);
// Clear typing indicator for this user
typingUsers.delete(connection.id);
// Broadcast message to all users
wsx.broadcast(
"#messages",
`
<div class="message">
<div class="message-header">
<span class="username">${messageObj.username}</span>
<span class="timestamp">${messageObj.timestamp.toLocaleTimeString()}</span>
</div>
<div class="message-content">${escapeHtml(messageObj.message)}</div>
</div>
`,
"afterbegin"
);
// Update typing indicators
wsx.broadcast("#typing-indicators", generateTypingIndicators());
// Clear the input for the sender
return {
id: request.id,
target: "#message-input",
html: "",
swap: "innerHTML",
};
});
// Typing indicator
wsx.on("typing", async (request, connection) => {
const user = users.get(connection.id);
if (!user) return;
const { isTyping } = request.data;
if (isTyping) {
typingUsers.set(connection.id, user.username);
} else {
typingUsers.delete(connection.id);
}
// Broadcast typing indicators to all users
wsx.broadcast("#typing-indicators", generateTypingIndicators());
});
function generateUserList() {
const userList = Array.from(users.values());
return `
<h3>Online Users (${userList.length})</h3>
<ul>
${userList
.map(
(user) => `
<li>
<span class="user-status online"></span>
${user.username}
</li>
`
)
.join("")}
</ul>
`;
}
function generateMessageHistory() {
return messages
.slice(-50)
.reverse()
.map(
(msg) => `
<div class="message">
<div class="message-header">
<span class="username">${msg.username}</span>
<span class="timestamp">${msg.timestamp.toLocaleTimeString()}</span>
</div>
<div class="message-content">${escapeHtml(msg.message)}</div>
</div>
`
)
.join("");
}
function generateTypingIndicators() {
const typing = Array.from(typingUsers.values());
if (typing.length === 0) return "";
return `
<div class="typing-indicator">
${typing.join(", ")} ${typing.length === 1 ? "is" : "are"} typing...
</div>
`;
}
function generateChatInterface(username) {
return `
<div class="chat-header">
<h2>Chat Room</h2>
<span class="current-user">Logged in as: ${username}</span>
</div>
<div class="chat-body">
<div class="messages-container">
<div id="messages"></div>
<div id="typing-indicators"></div>
</div>
<div class="message-input-container">
<form wx-send="send-message" wx-target="#message-input">
<input
id="message-input"
name="message"
type="text"
placeholder="Type a message..."
wx-send="typing"
wx-target="#typing-indicators"
wx-trigger="input throttle:500ms"
wx-data='{"isTyping": true}'
autocomplete="off"
>
<button type="submit">Send</button>
</form>
</div>
</div>
`;
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
<!DOCTYPE html>
<html>
<head>
<title>WSX Chat Room</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="/wsx.js"></script>
<style>
.chat-container {
height: 80vh;
display: flex;
flex-direction: column;
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: 1rem;
background: #f9fafb;
}
.message {
margin-bottom: 1rem;
padding: 0.75rem;
background: white;
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.message-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.username {
font-weight: bold;
color: #3b82f6;
}
.timestamp {
color: #6b7280;
font-size: 0.875rem;
}
.system-message {
text-align: center;
color: #6b7280;
font-style: italic;
margin: 0.5rem 0;
}
.typing-indicator {
color: #6b7280;
font-style: italic;
padding: 0.5rem;
}
.user-status {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 0.5rem;
}
.user-status.online {
background: #10b981;
}
.message-input-container {
padding: 1rem;
border-top: 1px solid #e5e7eb;
}
.message-input-container form {
display: flex;
gap: 0.5rem;
}
.message-input-container input {
flex: 1;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
}
.message-input-container button {
padding: 0.75rem 1.5rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
}
.message-input-container button:hover {
background: #2563eb;
}
</style>
</head>
<body class="bg-gray-100">
<div wx-config='{"url": "ws://localhost:3000/ws", "debug": true}'>
<div class="container mx-auto p-4">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- Main Chat Area -->
<div class="md:col-span-3">
<div class="bg-white rounded-lg shadow-lg">
<div id="error" class="text-red-500 p-4"></div>
<!-- Login Form -->
<div id="login-form" class="p-6">
<h1 class="text-2xl font-bold mb-4">Join Chat Room</h1>
<form wx-send="login" wx-target="#login-form">
<div class="mb-4">
<input
name="username"
type="text"
placeholder="Enter your username"
class="w-full p-3 border border-gray-300 rounded-lg"
required
/>
</div>
<button
type="submit"
class="w-full bg-blue-500 text-white p-3 rounded-lg hover:bg-blue-600"
>
Join Chat
</button>
</form>
</div>
<!-- Chat Interface (hidden until login) -->
<div id="chat-container" class="chat-container"></div>
</div>
</div>
<!-- Sidebar -->
<div class="bg-white rounded-lg shadow-lg p-4">
<div id="user-list">
<h3>Online Users (0)</h3>
<ul></ul>
</div>
</div>
</div>
</div>
</div>
<script>
// Handle typing indicator cleanup
let typingTimer;
document.addEventListener("wsx:beforeRequest", (event) => {
if (event.detail.request.handler === "typing") {
clearTimeout(typingTimer);
typingTimer = setTimeout(() => {
// Send stop typing after 2 seconds of inactivity
wsx.trigger('[wx-send="typing"]', { isTyping: false });
}, 2000);
}
});
// Auto-scroll to bottom of messages
document.addEventListener("wsx:afterSwap", (event) => {
if (event.detail.update.target === "#messages") {
const messagesContainer = document.querySelector(
".messages-container"
);
if (messagesContainer) {
messagesContainer.scrollTop = 0; // Scroll to top since we use afterbegin
}
}
});
// Focus input after login
document.addEventListener("wsx:afterSwap", (event) => {
if (event.detail.update.target === "#chat-container") {
const input = document.querySelector("#message-input");
if (input) {
input.focus();
}
}
});
</script>
</body>
</html>
// Send private message
wsx.on("private-message", async (request, connection) => {
const { recipient, message } = request.data;
const sender = users.get(connection.id);
if (!sender || !message.trim()) return;
// Find recipient connection
const recipientConnection = Array.from(users.entries()).find(
([, user]) => user.username === recipient
);
if (!recipientConnection) {
return {
id: request.id,
target: "#error",
html: '<div class="error">User not found</div>',
};
}
const [recipientId] = recipientConnection;
const timestamp = new Date().toLocaleTimeString();
// Send to recipient
wsx.sendToConnection(
recipientId,
"#messages",
`
<div class="message private">
<div class="message-header">
<span class="username">${sender.username} (private)</span>
<span class="timestamp">${timestamp}</span>
</div>
<div class="message-content">${escapeHtml(message)}</div>
</div>
`,
"afterbegin"
);
// Confirm to sender
return {
id: request.id,
target: "#messages",
html: `
<div class="message private sent">
<div class="message-header">
<span class="username">You to ${recipient}</span>
<span class="timestamp">${timestamp}</span>
</div>
<div class="message-content">${escapeHtml(message)}</div>
</div>
`,
swap: "afterbegin",
};
});
// Add reaction to message
wsx.on("add-reaction", async (request, connection) => {
const { messageId, emoji } = request.data;
const user = users.get(connection.id);
if (!user) return;
// Store reaction (you'd use a database in production)
const reactions = getMessageReactions(messageId);
reactions.push({ user: user.username, emoji });
// Broadcast updated reactions
wsx.broadcast(`#reactions-${messageId}`, generateReactions(reactions));
});
// Handle file upload
app.post("/upload", upload.single("file"), (req, res) => {
const file = req.file;
const user = req.body.username;
// Store file info
const fileMessage = {
id: Date.now(),
username: user,
type: "file",
filename: file.originalname,
size: file.size,
url: `/uploads/${file.filename}`,
timestamp: new Date(),
};
messages.push(fileMessage);
// Broadcast file message
wsx.broadcast(
"#messages",
`
<div class="message file">
<div class="message-header">
<span class="username">${user}</span>
<span class="timestamp">${fileMessage.timestamp.toLocaleTimeString()}</span>
</div>
<div class="message-content">
📎 <a href="${fileMessage.url}" target="_blank">${
fileMessage.filename
}</a>
<span class="file-size">(${formatFileSize(fileMessage.size)})</span>
</div>
</div>
`,
"afterbegin"
);
res.json({ success: true });
});
// Input sanitization
function sanitizeMessage(message) {
return message
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// Rate limiting
const rateLimiter = new Map();
wsx.on("send-message", async (request, connection) => {
const userId = connection.id;
const now = Date.now();
const userRateLimit = rateLimiter.get(userId) || {
count: 0,
resetTime: now + 60000,
};
if (now > userRateLimit.resetTime) {
userRateLimit.count = 0;
userRateLimit.resetTime = now + 60000;
}
if (userRateLimit.count >= 10) {
return {
id: request.id,
target: "#error",
html: '<div class="error">Rate limit exceeded. Please wait.</div>',
};
}
userRateLimit.count++;
rateLimiter.set(userId, userRateLimit);
// Process message...
});