feat: initial mIRCcloud.com scaffold

- Express + TypeScript backend with IRC connection manager
- React + Vite frontend with mIRC-inspired theme
- Drizzle ORM + PostgreSQL schema (users, connections, channels, messages)
- Redis pub/sub for real-time WebSocket delivery across replicas
- IRC color code parser (16 + 99 extended palette)
- Kubernetes manifests for homecloud deployment
- Multi-stage Dockerfile with non-root user
- docker-compose for local development
This commit is contained in:
Jason Preston
2026-08-08 23:50:08 -06:00
commit c59ac7ad0f
52 changed files with 5723 additions and 0 deletions
+400
View File
@@ -0,0 +1,400 @@
import { WebSocketServer, WebSocket } from 'ws';
import { Server as HttpServer } from 'http';
import Redis from 'ioredis';
import { config } from '../config.js';
import { authenticateWs, JwtPayload } from '../auth/index.js';
import { connectionManager } from '../irc/connection-manager.js';
import { db } from '../db/index.js';
import { connections, messages, readMarkers } from '../db/schema.js';
import { eq, and, lt, desc } from 'drizzle-orm';
interface AuthenticatedSocket {
ws: WebSocket;
user: JwtPayload;
subscriber: Redis;
alive: boolean;
}
const activeSockets: Map<number, Set<AuthenticatedSocket>> = new Map();
export function createWebSocketServer(server: HttpServer): WebSocketServer {
const wss = new WebSocketServer({ server, path: '/ws' });
const heartbeatInterval = setInterval(() => {
for (const [, sockets] of activeSockets) {
for (const socket of sockets) {
if (!socket.alive) {
socket.ws.terminate();
continue;
}
socket.alive = false;
socket.ws.ping();
}
}
}, 30000);
wss.on('close', () => {
clearInterval(heartbeatInterval);
});
wss.on('connection', async (ws, req) => {
const user = authenticateWs(req);
if (!user) {
ws.close(4001, 'Authentication failed');
return;
}
const subscriber = new Redis(config.REDIS_URL);
const socket: AuthenticatedSocket = {
ws,
user,
subscriber,
alive: true,
};
let userSockets = activeSockets.get(user.userId);
if (!userSockets) {
userSockets = new Set();
activeSockets.set(user.userId, userSockets);
}
userSockets.add(socket);
await subscriber.subscribe(`irc:events:${user.userId}`);
subscriber.on('message', (_channel: string, message: string) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(message);
}
});
ws.on('pong', () => {
socket.alive = true;
});
ws.on('message', async (data) => {
try {
const msg = JSON.parse(data.toString());
await handleClientCommand(socket, msg);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Invalid message';
ws.send(JSON.stringify({ type: 'error', error: errorMsg }));
}
});
ws.on('close', async () => {
await subscriber.unsubscribe(`irc:events:${user.userId}`);
await subscriber.quit();
const sockets = activeSockets.get(user.userId);
if (sockets) {
sockets.delete(socket);
if (sockets.size === 0) {
activeSockets.delete(user.userId);
}
}
});
ws.on('error', () => {
ws.terminate();
});
await sendInitialState(socket);
});
return wss;
}
async function sendInitialState(socket: AuthenticatedSocket): Promise<void> {
const { user, ws } = socket;
const userConnections = await db.query.connections.findMany({
where: eq(connections.userId, user.userId),
with: {
channels: true,
},
});
const connectionsState = userConnections.map((conn) => ({
id: conn.id,
name: conn.name,
hostname: conn.hostname,
port: conn.port,
tls: conn.tls,
nick: conn.nick,
connected: conn.connected,
channels: conn.channels.map((ch) => ({
id: ch.id,
name: ch.name,
joined: ch.joined,
topic: ch.topic,
})),
}));
ws.send(
JSON.stringify({
type: 'initial_state',
connections: connectionsState,
})
);
for (const conn of userConnections) {
for (const ch of conn.channels) {
if (ch.joined) {
const recentMessages = await db.query.messages.findMany({
where: and(
eq(messages.connectionId, conn.id),
eq(messages.channelName, ch.name)
),
orderBy: [desc(messages.timestamp)],
limit: 50,
});
ws.send(
JSON.stringify({
type: 'backlog',
connectionId: conn.id,
buffer: ch.name,
messages: recentMessages.reverse(),
})
);
}
}
const consoleMessages = await db.query.messages.findMany({
where: and(
eq(messages.connectionId, conn.id),
eq(messages.bufferType, 'console')
),
orderBy: [desc(messages.timestamp)],
limit: 50,
});
if (consoleMessages.length > 0) {
ws.send(
JSON.stringify({
type: 'backlog',
connectionId: conn.id,
buffer: null,
messages: consoleMessages.reverse(),
})
);
}
}
}
async function handleClientCommand(
socket: AuthenticatedSocket,
msg: Record<string, unknown>
): Promise<void> {
const { user, ws } = socket;
switch (msg.command) {
case 'send_message': {
const connectionId = msg.connectionId as number;
const target = msg.target as string;
const text = msg.text as string;
const client = connectionManager.getConnection(user.userId, connectionId);
if (!client) {
ws.send(JSON.stringify({ type: 'error', error: 'Connection not found or not active' }));
return;
}
client.say(target, text);
break;
}
case 'join_channel': {
const connectionId = msg.connectionId as number;
const channel = msg.channel as string;
const key = msg.key as string | undefined;
const client = connectionManager.getConnection(user.userId, connectionId);
if (!client) {
ws.send(JSON.stringify({ type: 'error', error: 'Connection not found or not active' }));
return;
}
if (key) {
client.join(channel, key);
} else {
client.join(channel);
}
break;
}
case 'part_channel': {
const connectionId = msg.connectionId as number;
const channel = msg.channel as string;
const reason = msg.reason as string | undefined;
const client = connectionManager.getConnection(user.userId, connectionId);
if (!client) {
ws.send(JSON.stringify({ type: 'error', error: 'Connection not found or not active' }));
return;
}
client.part(channel, reason || '');
break;
}
case 'get_backlog': {
const connectionId = msg.connectionId as number;
const buffer = msg.buffer as string | null;
const before = msg.before as string | undefined;
const limit = Math.min((msg.limit as number) || 100, 500);
const conditions = [eq(messages.connectionId, connectionId)];
if (buffer) {
conditions.push(eq(messages.channelName, buffer));
} else {
conditions.push(eq(messages.bufferType, 'console'));
}
if (before) {
conditions.push(lt(messages.id, BigInt(before)));
}
const result = await db.query.messages.findMany({
where: and(...conditions),
orderBy: [desc(messages.timestamp)],
limit,
});
ws.send(
JSON.stringify({
type: 'backlog',
connectionId,
buffer,
messages: result.reverse(),
})
);
break;
}
case 'mark_read': {
const connectionId = msg.connectionId as number;
const channelName = (msg.channel as string) || null;
const lastReadId = msg.lastReadId as string;
const existing = await db.query.readMarkers.findFirst({
where: and(
eq(readMarkers.userId, user.userId),
eq(readMarkers.connectionId, connectionId),
channelName
? eq(readMarkers.channelName, channelName)
: eq(readMarkers.channelName, '')
),
});
if (existing) {
await db
.update(readMarkers)
.set({ lastReadId: BigInt(lastReadId), updatedAt: new Date() })
.where(eq(readMarkers.id, existing.id));
} else {
await db.insert(readMarkers).values({
userId: user.userId,
connectionId,
channelName,
lastReadId: BigInt(lastReadId),
});
}
ws.send(JSON.stringify({ type: 'mark_read_ack', connectionId, channel: channelName }));
break;
}
case 'add_connection': {
const connData = msg.data as Record<string, unknown>;
const [newConn] = await db
.insert(connections)
.values({
userId: user.userId,
name: connData.name as string,
hostname: connData.hostname as string,
port: (connData.port as number) || 6667,
tls: (connData.tls as boolean) || false,
nick: connData.nick as string,
username: (connData.username as string) || null,
realname: (connData.realname as string) || null,
password: (connData.password as string) || null,
saslUsername: (connData.saslUsername as string) || null,
saslPassword: (connData.saslPassword as string) || null,
autoConnect: (connData.autoConnect as boolean) || false,
})
.returning();
ws.send(JSON.stringify({ type: 'connection_added', connection: newConn }));
break;
}
case 'remove_connection': {
const connectionId = msg.connectionId as number;
const conn = await db.query.connections.findFirst({
where: and(
eq(connections.id, connectionId),
eq(connections.userId, user.userId)
),
});
if (!conn) {
ws.send(JSON.stringify({ type: 'error', error: 'Connection not found' }));
return;
}
await connectionManager.disconnect(user.userId, connectionId);
await db.delete(connections).where(eq(connections.id, connectionId));
ws.send(JSON.stringify({ type: 'connection_removed', connectionId }));
break;
}
case 'list_connections': {
const userConnections = await db.query.connections.findMany({
where: eq(connections.userId, user.userId),
with: { channels: true },
});
ws.send(
JSON.stringify({
type: 'connections_list',
connections: userConnections.map((conn) => ({
id: conn.id,
name: conn.name,
hostname: conn.hostname,
port: conn.port,
tls: conn.tls,
nick: conn.nick,
connected: conn.connected,
channels: conn.channels.map((ch) => ({
id: ch.id,
name: ch.name,
joined: ch.joined,
topic: ch.topic,
})),
})),
})
);
break;
}
default:
ws.send(JSON.stringify({ type: 'error', error: `Unknown command: ${msg.command}` }));
}
}
export function closeAllSockets(): void {
for (const [, sockets] of activeSockets) {
for (const socket of sockets) {
socket.subscriber.quit();
socket.ws.close(1001, 'Server shutting down');
}
}
activeSockets.clear();
}