feat: websockets (comment posting + liking)

This commit is contained in:
Face 2025-05-25 12:06:04 +03:00
parent 251609d7b8
commit 3f137e5c3c
15 changed files with 2200 additions and 5 deletions

34
website/websocket/.gitignore vendored Normal file
View file

@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

View file

@ -0,0 +1,15 @@
# websocket
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run src/main.ts
```
This project was created using `bun init` in bun v1.2.11. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.

View file

@ -0,0 +1,53 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "websocket",
"dependencies": {
"dotenv": "^16.5.0",
"ioredis": "^5.6.1",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@ioredis/commands": ["@ioredis/commands@1.2.0", "", {}, "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg=="],
"@types/bun": ["@types/bun@1.2.14", "", { "dependencies": { "bun-types": "1.2.14" } }, "sha512-VsFZKs8oKHzI7zwvECiAJ5oSorWndIWEVhfbYqZd4HI/45kzW7PN2Rr5biAzvGvRuNmYLSANY+H59ubHq8xw7Q=="],
"@types/node": ["@types/node@22.15.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ=="],
"bun-types": ["bun-types@1.2.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-Kuh4Ub28ucMRWeiUUWMHsT9Wcbr4H3kLIO72RZZElSDxSu7vpetRvxIUDUaW6QtaIeixIpm7OXtNnZPf82EzwA=="],
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"dotenv": ["dotenv@16.5.0", "", {}, "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg=="],
"ioredis": ["ioredis@5.6.1", "", { "dependencies": { "@ioredis/commands": "^1.1.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA=="],
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
}
}

View file

@ -0,0 +1,16 @@
{
"name": "websocket",
"module": "src/main.ts",
"type": "module",
"private": true,
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"dotenv": "^16.5.0",
"ioredis": "^5.6.1"
}
}

View file

@ -0,0 +1,158 @@
import type { ServerWebSocket } from 'bun';
import Redis from 'ioredis';
import { config } from 'dotenv';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
config({ path: join(__dirname, '../../.env') });
if (!process.env.REDIS_URL) {
console.error("REDIS_URL is not defined in environment variables.");
process.exit(1);
}
const redis = new Redis(process.env.REDIS_URL);
redis.on('error', (err) => console.error('Redis Client Error', err));
redis.on('connect', () => console.log('Connected to Redis'));
redis.psubscribe('comments:*');
redis.on('pmessage', (_pattern, channel, msg) => {
try {
const coinSymbol = channel.substring('comments:'.length);
const sockets = coinSockets.get(coinSymbol);
if (sockets) {
for (const ws of sockets) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(msg);
}
}
}
} catch (error) {
console.error('Error processing Redis message:', error);
}
});
const HEARTBEAT_INTERVAL = 30_000;
type WebSocketData = {
coinSymbol?: string;
lastActivity: number;
};
const coinSockets = new Map<string, Set<ServerWebSocket<WebSocketData>>>();
const pingIntervals = new WeakMap<ServerWebSocket<WebSocketData>, NodeJS.Timeout>();
function handleSetCoin(ws: ServerWebSocket<WebSocketData>, coinSymbol: string) {
if (ws.data.coinSymbol) {
const prev = coinSockets.get(ws.data.coinSymbol);
if (prev) {
prev.delete(ws);
if (prev.size === 0) {
coinSockets.delete(ws.data.coinSymbol);
}
}
}
ws.data.coinSymbol = coinSymbol;
if (!coinSockets.has(coinSymbol)) {
coinSockets.set(coinSymbol, new Set([ws]));
} else {
coinSockets.get(coinSymbol)!.add(ws);
}
}
function checkConnections() {
const now = Date.now();
for (const [coinSymbol, sockets] of coinSockets.entries()) {
const staleSockets = Array.from(sockets).filter(ws => now - ws.data.lastActivity > HEARTBEAT_INTERVAL * 2);
for (const socket of staleSockets) {
socket.terminate();
}
}
}
setInterval(checkConnections, HEARTBEAT_INTERVAL);
const server = Bun.serve<WebSocketData, undefined>({
port: Number(process.env.PORT) || 8080,
fetch(request, server) {
const url = new URL(request.url);
if (url.pathname === '/health') {
return new Response(JSON.stringify({
status: 'ok',
timestamp: new Date().toISOString(),
activeConnections: Array.from(coinSockets.values()).reduce((total, set) => total + set.size, 0)
}), {
headers: { 'Content-Type': 'application/json' }
});
}
const upgraded = server.upgrade(request, {
data: {
coinSymbol: undefined,
lastActivity: Date.now()
}
});
return upgraded ? undefined : new Response('Upgrade failed', { status: 500 });
},
websocket: {
message(ws, msg) {
ws.data.lastActivity = Date.now();
if (typeof msg !== 'string') return;
try {
const data = JSON.parse(msg) as {
type: string;
coinSymbol?: string;
};
if (data.type === 'set_coin' && data.coinSymbol) {
handleSetCoin(ws, data.coinSymbol);
}
} catch (error) {
console.error('Message parsing error:', error);
}
},
open(ws) {
const interval = setInterval(() => {
if (ws.readyState === 1) {
ws.data.lastActivity = Date.now();
ws.send(JSON.stringify({ type: 'ping' }));
} else {
clearInterval(interval);
}
}, HEARTBEAT_INTERVAL);
pingIntervals.set(ws, interval);
}, close(ws) {
const interval = pingIntervals.get(ws);
if (interval) {
clearInterval(interval);
pingIntervals.delete(ws);
}
if (ws.data.coinSymbol) {
const sockets = coinSockets.get(ws.data.coinSymbol);
if (sockets) {
sockets.delete(ws);
if (sockets.size === 0) {
coinSockets.delete(ws.data.coinSymbol);
}
}
}
}
}
});
console.log(`WebSocket server running on port ${server.port}`);
console.log('Server listening for connections...');
console.log('Health check available at: http://localhost:8080/health');

View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}