Modernize collaborative pixel art platform to production-ready architecture
Major refactor from simple HTML/JS app to modern full-stack TypeScript application: ## Architecture Changes - Migrated to monorepo structure with workspaces (backend, frontend, shared) - Backend: Node.js + Express + TypeScript + Socket.IO - Frontend: Next.js 15.5 + React 19 + TypeScript + Tailwind CSS - Shared: Common types and utilities across packages ## Key Features Implemented - Real-time WebSocket collaboration via Socket.IO - Virtual canvas with chunked loading for performance - Modern UI with dark mode and responsive design - Mock database system for easy development (Redis/PostgreSQL compatible) - Comprehensive error handling and rate limiting - User presence and cursor tracking - Infinite canvas support with zoom/pan controls ## Performance Optimizations - Canvas virtualization - only renders visible viewport - Chunked pixel data loading (64x64 pixel chunks) - Optimized WebSocket protocol - Memory-efficient state management with Zustand ## Development Experience - Full TypeScript support across all packages - Hot reload for both frontend and backend - Docker support for production deployment - Comprehensive linting and formatting - Automated development server startup ## Fixed Issues - Corrected start script paths - Updated environment configuration - Fixed ESLint configuration issues - Ensured all dependencies are properly installed - Verified build process works correctly
This commit is contained in:
parent
8e02486a2a
commit
3ce5a97422
69 changed files with 17771 additions and 1589 deletions
30
backend/.env.example
Normal file
30
backend/.env.example
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Server Configuration
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
# Security
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||
|
||||
# Database Configuration
|
||||
REDIS_URL=redis://localhost:6379
|
||||
REDIS_KEY_PREFIX=gaplace:
|
||||
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=gaplace
|
||||
POSTGRES_USER=gaplace
|
||||
POSTGRES_PASSWORD=password
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_PIXELS_PER_MINUTE=60
|
||||
RATE_LIMIT_PIXELS_PER_HOUR=1000
|
||||
RATE_LIMIT_CURSOR_PER_SECOND=10
|
||||
|
||||
# Canvas Configuration
|
||||
MAX_CANVAS_SIZE=10000
|
||||
DEFAULT_CANVAS_SIZE=1000
|
||||
CHUNK_SIZE=64
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
9
backend/.eslintrc.json
Normal file
9
backend/.eslintrc.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": ["../.eslintrc.json"],
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.json"
|
||||
}
|
||||
}
|
||||
26
backend/Dockerfile
Normal file
26
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY backend/package*.json ./
|
||||
COPY shared/ ../shared/
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY backend/ .
|
||||
|
||||
# Build TypeScript
|
||||
RUN npm run build
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3001
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3001/health || exit 1
|
||||
|
||||
# Start the application
|
||||
CMD ["npm", "start"]
|
||||
47
backend/package.json
Normal file
47
backend/package.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"name": "@gaplace/backend",
|
||||
"version": "1.0.0",
|
||||
"description": "GaPlace backend server with TypeScript, Redis, and WebSocket support",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"clean": "rm -rf dist",
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gaplace/shared": "file:../shared",
|
||||
"express": "^4.18.2",
|
||||
"socket.io": "^4.7.4",
|
||||
"redis": "^4.6.12",
|
||||
"cors": "^2.8.5",
|
||||
"helmet": "^7.1.0",
|
||||
"compression": "^1.7.4",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"uuid": "^9.0.1",
|
||||
"pg": "^8.11.3",
|
||||
"dotenv": "^16.3.1",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@types/pg": "^8.10.9",
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/jest": "^29.5.11",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
195
backend/src/config/database-dev.ts
Normal file
195
backend/src/config/database-dev.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
// Development database configuration without external dependencies
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// Mock Redis client for development
|
||||
class MockRedisClient extends EventEmitter {
|
||||
private storage = new Map<string, any>();
|
||||
private isConnected = false;
|
||||
|
||||
async connect() {
|
||||
this.isConnected = true;
|
||||
console.log('✅ Connected to Mock Redis (Development Mode)');
|
||||
this.emit('connect');
|
||||
return this;
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
this.isConnected = false;
|
||||
return this;
|
||||
}
|
||||
|
||||
async set(key: string, value: string) {
|
||||
this.storage.set(key, value);
|
||||
return 'OK';
|
||||
}
|
||||
|
||||
async get(key: string) {
|
||||
return this.storage.get(key) || null;
|
||||
}
|
||||
|
||||
async incr(key: string) {
|
||||
const current = parseInt(this.storage.get(key) || '0');
|
||||
const newValue = current + 1;
|
||||
this.storage.set(key, newValue.toString());
|
||||
return newValue;
|
||||
}
|
||||
|
||||
async expire(key: string, seconds: number) {
|
||||
// In a real implementation, you'd set a timeout
|
||||
return 1;
|
||||
}
|
||||
|
||||
async hSet(key: string, field: string | Record<string, any>, value?: string) {
|
||||
if (typeof field === 'string' && value !== undefined) {
|
||||
let hash = this.storage.get(key);
|
||||
if (!hash || typeof hash !== 'object') {
|
||||
hash = {};
|
||||
}
|
||||
hash[field] = value;
|
||||
this.storage.set(key, hash);
|
||||
return 1;
|
||||
} else if (typeof field === 'object') {
|
||||
let hash = this.storage.get(key);
|
||||
if (!hash || typeof hash !== 'object') {
|
||||
hash = {};
|
||||
}
|
||||
Object.assign(hash, field);
|
||||
this.storage.set(key, hash);
|
||||
return Object.keys(field).length;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async hGetAll(key: string) {
|
||||
const hash = this.storage.get(key);
|
||||
return hash && typeof hash === 'object' ? hash : {};
|
||||
}
|
||||
|
||||
async sAdd(key: string, member: string) {
|
||||
let set = this.storage.get(key);
|
||||
if (!Array.isArray(set)) {
|
||||
set = [];
|
||||
}
|
||||
if (!set.includes(member)) {
|
||||
set.push(member);
|
||||
this.storage.set(key, set);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async sRem(key: string, member: string) {
|
||||
let set = this.storage.get(key);
|
||||
if (Array.isArray(set)) {
|
||||
const index = set.indexOf(member);
|
||||
if (index > -1) {
|
||||
set.splice(index, 1);
|
||||
this.storage.set(key, set);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async sMembers(key: string) {
|
||||
const set = this.storage.get(key);
|
||||
return Array.isArray(set) ? set : [];
|
||||
}
|
||||
|
||||
async sCard(key: string) {
|
||||
const set = this.storage.get(key);
|
||||
return Array.isArray(set) ? set.length : 0;
|
||||
}
|
||||
|
||||
async multi() {
|
||||
// Simplified mock - just return this for compatibility
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
// Mock PostgreSQL pool for development
|
||||
class MockPgPool extends EventEmitter {
|
||||
private isInitialized = false;
|
||||
|
||||
async connect() {
|
||||
if (!this.isInitialized) {
|
||||
console.log('✅ Connected to Mock PostgreSQL (Development Mode)');
|
||||
this.isInitialized = true;
|
||||
}
|
||||
return {
|
||||
query: async (sql: string, params?: any[]) => {
|
||||
// Mock responses for different queries
|
||||
if (sql.includes('CREATE TABLE')) {
|
||||
return { rows: [] };
|
||||
}
|
||||
if (sql.includes('INSERT INTO users')) {
|
||||
return {
|
||||
rows: [{
|
||||
id: 'mock-user-id',
|
||||
username: 'MockUser',
|
||||
email: 'mock@example.com',
|
||||
is_guest: true,
|
||||
created_at: new Date(),
|
||||
last_seen: new Date()
|
||||
}]
|
||||
};
|
||||
}
|
||||
if (sql.includes('SELECT') && sql.includes('users')) {
|
||||
return {
|
||||
rows: [{
|
||||
id: 'mock-user-id',
|
||||
username: 'MockUser',
|
||||
email: 'mock@example.com',
|
||||
is_guest: true,
|
||||
created_at: new Date(),
|
||||
last_seen: new Date()
|
||||
}]
|
||||
};
|
||||
}
|
||||
return { rows: [] };
|
||||
},
|
||||
release: () => {}
|
||||
};
|
||||
}
|
||||
|
||||
async query(sql: string, params?: any[]) {
|
||||
const client = await this.connect();
|
||||
const result = await client.query(sql, params);
|
||||
client.release();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export const redisClient = new MockRedisClient() as any;
|
||||
export const pgPool = new MockPgPool() as any;
|
||||
|
||||
export async function initializeDatabase(): Promise<void> {
|
||||
try {
|
||||
console.log('🔌 Initializing development database (Mock)...');
|
||||
|
||||
// Connect to mock Redis
|
||||
await redisClient.connect();
|
||||
|
||||
// Test PostgreSQL connection
|
||||
const client = await pgPool.connect();
|
||||
console.log('✅ Connected to Mock PostgreSQL');
|
||||
client.release();
|
||||
|
||||
// Create mock tables
|
||||
await createTables();
|
||||
} catch (error) {
|
||||
console.error('❌ Database initialization failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function createTables(): Promise<void> {
|
||||
try {
|
||||
await pgPool.query(`CREATE TABLE IF NOT EXISTS users (...)`);
|
||||
await pgPool.query(`CREATE TABLE IF NOT EXISTS canvases (...)`);
|
||||
await pgPool.query(`CREATE TABLE IF NOT EXISTS user_sessions (...)`);
|
||||
console.log('✅ Database tables created/verified (Mock)');
|
||||
} catch (error) {
|
||||
console.log('✅ Mock tables setup complete');
|
||||
}
|
||||
}
|
||||
27
backend/src/config/database-factory.ts
Normal file
27
backend/src/config/database-factory.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// Database factory that chooses between production and development database
|
||||
|
||||
// Check if we should use development mode (no Redis/PostgreSQL available)
|
||||
const useDevelopmentMode = process.env.NODE_ENV === 'development' &&
|
||||
(process.env.USE_MOCK_DB === 'true' || !process.env.REDIS_URL?.includes('://'));
|
||||
|
||||
let redisClient: any;
|
||||
let pgPool: any;
|
||||
let initializeDatabase: () => Promise<void>;
|
||||
|
||||
if (useDevelopmentMode) {
|
||||
// Use development mock
|
||||
const devDb = require('./database-dev');
|
||||
redisClient = devDb.redisClient;
|
||||
pgPool = devDb.pgPool;
|
||||
initializeDatabase = devDb.initializeDatabase;
|
||||
console.log('📦 Using development database (Mock)');
|
||||
} else {
|
||||
// Use production database
|
||||
const prodDb = require('./database');
|
||||
redisClient = prodDb.redisClient;
|
||||
pgPool = prodDb.pgPool;
|
||||
initializeDatabase = prodDb.initializeDatabase;
|
||||
console.log('🔥 Using production database');
|
||||
}
|
||||
|
||||
export { redisClient, pgPool, initializeDatabase };
|
||||
126
backend/src/config/database.ts
Normal file
126
backend/src/config/database.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { createClient } from 'redis';
|
||||
import pkg from 'pg';
|
||||
const { Pool } = pkg;
|
||||
|
||||
export interface DatabaseConfig {
|
||||
redis: {
|
||||
url: string;
|
||||
maxRetriesPerRequest: number;
|
||||
};
|
||||
postgres: {
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
user: string;
|
||||
password: string;
|
||||
max: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const databaseConfig: DatabaseConfig = {
|
||||
redis: {
|
||||
url: process.env.REDIS_URL || 'redis://localhost:6379',
|
||||
maxRetriesPerRequest: 3,
|
||||
},
|
||||
postgres: {
|
||||
host: process.env.POSTGRES_HOST || 'localhost',
|
||||
port: parseInt(process.env.POSTGRES_PORT || '5432'),
|
||||
database: process.env.POSTGRES_DB || 'gaplace',
|
||||
user: process.env.POSTGRES_USER || 'gaplace',
|
||||
password: process.env.POSTGRES_PASSWORD || 'password',
|
||||
max: 20,
|
||||
},
|
||||
};
|
||||
|
||||
// Redis client
|
||||
export const redisClient = createClient({
|
||||
url: databaseConfig.redis.url,
|
||||
});
|
||||
|
||||
redisClient.on('error', (err) => {
|
||||
console.error('Redis Client Error:', err);
|
||||
});
|
||||
|
||||
redisClient.on('connect', () => {
|
||||
console.log('✅ Connected to Redis');
|
||||
});
|
||||
|
||||
// PostgreSQL client
|
||||
export const pgPool = new Pool(databaseConfig.postgres);
|
||||
|
||||
pgPool.on('error', (err) => {
|
||||
console.error('PostgreSQL Pool Error:', err);
|
||||
});
|
||||
|
||||
export async function initializeDatabase(): Promise<void> {
|
||||
try {
|
||||
// Connect to Redis
|
||||
await redisClient.connect();
|
||||
|
||||
// Test PostgreSQL connection
|
||||
const client = await pgPool.connect();
|
||||
console.log('✅ Connected to PostgreSQL');
|
||||
client.release();
|
||||
|
||||
// Create tables if they don't exist
|
||||
await createTables();
|
||||
} catch (error) {
|
||||
console.error('❌ Database initialization failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function createTables(): Promise<void> {
|
||||
const client = await pgPool.connect();
|
||||
|
||||
try {
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE,
|
||||
password_hash VARCHAR(255),
|
||||
avatar_url TEXT,
|
||||
is_guest BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
last_seen TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS canvases (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
width INTEGER NOT NULL DEFAULT 1000,
|
||||
height INTEGER NOT NULL DEFAULT 1000,
|
||||
chunk_size INTEGER NOT NULL DEFAULT 64,
|
||||
is_public BOOLEAN DEFAULT TRUE,
|
||||
created_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES users(id),
|
||||
canvas_id UUID REFERENCES canvases(id),
|
||||
session_token VARCHAR(255) UNIQUE NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
last_activity TIMESTAMP DEFAULT NOW(),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_canvases_public ON canvases(is_public);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_active ON user_sessions(is_active, last_activity);
|
||||
`);
|
||||
|
||||
console.log('✅ Database tables created/verified');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
43
backend/src/config/env.ts
Normal file
43
backend/src/config/env.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const config = {
|
||||
port: parseInt(process.env.PORT || '3001'),
|
||||
host: process.env.HOST || 'localhost',
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
jwtSecret: process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-in-production',
|
||||
corsOrigin: process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',') : ['http://localhost:3000'],
|
||||
|
||||
// Rate limiting
|
||||
rateLimits: {
|
||||
pixelsPerMinute: parseInt(process.env.RATE_LIMIT_PIXELS_PER_MINUTE || '60'),
|
||||
pixelsPerHour: parseInt(process.env.RATE_LIMIT_PIXELS_PER_HOUR || '1000'),
|
||||
cursorUpdatesPerSecond: parseInt(process.env.RATE_LIMIT_CURSOR_PER_SECOND || '10'),
|
||||
},
|
||||
|
||||
// Canvas settings
|
||||
canvas: {
|
||||
maxSize: parseInt(process.env.MAX_CANVAS_SIZE || '10000'),
|
||||
defaultSize: parseInt(process.env.DEFAULT_CANVAS_SIZE || '1000'),
|
||||
chunkSize: parseInt(process.env.CHUNK_SIZE || '64'),
|
||||
},
|
||||
|
||||
// Redis settings
|
||||
redis: {
|
||||
url: process.env.REDIS_URL || 'redis://localhost:6379',
|
||||
keyPrefix: process.env.REDIS_KEY_PREFIX || 'gaplace:',
|
||||
},
|
||||
|
||||
// Logging
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
} as const;
|
||||
|
||||
export function validateConfig(): void {
|
||||
const required = ['JWT_SECRET'];
|
||||
const missing = required.filter(key => !process.env[key]);
|
||||
|
||||
if (missing.length > 0 && config.nodeEnv === 'production') {
|
||||
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
150
backend/src/server.ts
Normal file
150
backend/src/server.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import express from 'express';
|
||||
import { createServer } from 'http';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import compression from 'compression';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { config, validateConfig } from './config/env';
|
||||
import { initializeDatabase } from './config/database-factory';
|
||||
import { WebSocketService } from './services/WebSocketService';
|
||||
|
||||
// Validate environment configuration
|
||||
validateConfig();
|
||||
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
||||
fontSrc: ["'self'", "https://fonts.gstatic.com"],
|
||||
imgSrc: ["'self'", "data:", "https:"],
|
||||
connectSrc: ["'self'", "ws:", "wss:"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// CORS configuration
|
||||
app.use(cors({
|
||||
origin: config.corsOrigin,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
}));
|
||||
|
||||
// Compression and parsing
|
||||
app.use(compression());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Rate limiting
|
||||
const generalLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 1000, // Limit each IP to 1000 requests per windowMs
|
||||
message: 'Too many requests from this IP, please try again later.',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
app.use(generalLimiter);
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '2.0.0'
|
||||
});
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.get('/api/canvas/:id/stats', async (req, res) => {
|
||||
try {
|
||||
// TODO: Implement canvas stats endpoint
|
||||
res.json({
|
||||
totalPixels: 0,
|
||||
activeUsers: 0,
|
||||
lastActivity: Date.now()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching canvas stats:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Serve static files for development (in production, use a reverse proxy)
|
||||
if (config.nodeEnv === 'development') {
|
||||
app.use(express.static('public'));
|
||||
}
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
console.error('Unhandled error:', err);
|
||||
res.status(500).json({
|
||||
error: config.nodeEnv === 'development' ? err.message : 'Internal server error'
|
||||
});
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use('*', (req, res) => {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
});
|
||||
|
||||
async function startServer() {
|
||||
try {
|
||||
// Initialize database connections
|
||||
console.log('🔌 Initializing database connections...');
|
||||
await initializeDatabase();
|
||||
|
||||
// Initialize WebSocket service
|
||||
console.log('🔌 Initializing WebSocket service...');
|
||||
const wsService = new WebSocketService(server);
|
||||
|
||||
// Start server
|
||||
server.listen(config.port, config.host, () => {
|
||||
console.log(`🚀 GaPlace server running on http://${config.host}:${config.port}`);
|
||||
console.log(`📁 Environment: ${config.nodeEnv}`);
|
||||
console.log(`🌐 CORS origins: ${Array.isArray(config.corsOrigin) ? config.corsOrigin.join(', ') : config.corsOrigin}`);
|
||||
console.log(`📊 Canvas max size: ${config.canvas.maxSize}x${config.canvas.maxSize}`);
|
||||
console.log(`⚡ Ready for connections!`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('📛 SIGTERM received, shutting down gracefully');
|
||||
server.close(() => {
|
||||
console.log('💤 Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('📛 SIGINT received, shutting down gracefully');
|
||||
server.close(() => {
|
||||
console.log('💤 Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle uncaught exceptions
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('❌ Uncaught Exception:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('❌ Unhandled Rejection at:', promise, 'reason:', reason);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
startServer();
|
||||
153
backend/src/services/CanvasService.ts
Normal file
153
backend/src/services/CanvasService.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { redisClient } from '../config/database-factory';
|
||||
import {
|
||||
Pixel,
|
||||
PixelChunk,
|
||||
getChunkCoordinates,
|
||||
getChunkKey,
|
||||
getPixelKey,
|
||||
CANVAS_CONFIG
|
||||
} from '@gaplace/shared';
|
||||
import { config } from '../config/env';
|
||||
|
||||
export class CanvasService {
|
||||
private readonly keyPrefix = config.redis.keyPrefix;
|
||||
|
||||
async placePixel(canvasId: string, x: number, y: number, color: string, userId: string): Promise<boolean> {
|
||||
try {
|
||||
const { chunkX, chunkY } = getChunkCoordinates(x, y);
|
||||
const chunkKey = `${this.keyPrefix}canvas:${canvasId}:chunk:${getChunkKey(chunkX, chunkY)}`;
|
||||
const pixelKey = getPixelKey(x % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE, y % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE);
|
||||
|
||||
// Simple approach without pipeline for better compatibility
|
||||
await redisClient.hSet(chunkKey, pixelKey, color);
|
||||
|
||||
// Update chunk metadata
|
||||
await redisClient.hSet(`${chunkKey}:meta`, {
|
||||
lastModified: Date.now().toString(),
|
||||
lastUser: userId,
|
||||
});
|
||||
|
||||
// Track user pixel count
|
||||
await redisClient.incr(`${this.keyPrefix}user:${userId}:pixels`);
|
||||
|
||||
// Update canvas stats
|
||||
await redisClient.incr(`${this.keyPrefix}canvas:${canvasId}:totalPixels`);
|
||||
await redisClient.set(`${this.keyPrefix}canvas:${canvasId}:lastActivity`, Date.now().toString());
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error placing pixel:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getChunk(canvasId: string, chunkX: number, chunkY: number): Promise<PixelChunk | null> {
|
||||
try {
|
||||
const chunkKey = `${this.keyPrefix}canvas:${canvasId}:chunk:${getChunkKey(chunkX, chunkY)}`;
|
||||
const [pixelData, metadata] = await Promise.all([
|
||||
redisClient.hGetAll(chunkKey),
|
||||
redisClient.hGetAll(`${chunkKey}:meta`)
|
||||
]);
|
||||
|
||||
if (Object.keys(pixelData).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pixels = new Map<string, string>();
|
||||
for (const [key, color] of Object.entries(pixelData)) {
|
||||
pixels.set(key, String(color));
|
||||
}
|
||||
|
||||
return {
|
||||
chunkX,
|
||||
chunkY,
|
||||
pixels,
|
||||
lastModified: parseInt(metadata.lastModified || '0'),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting chunk:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getVisibleChunks(canvasId: string, startX: number, startY: number, endX: number, endY: number): Promise<PixelChunk[]> {
|
||||
const chunks: PixelChunk[] = [];
|
||||
|
||||
const startChunkX = Math.floor(startX / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE);
|
||||
const startChunkY = Math.floor(startY / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE);
|
||||
const endChunkX = Math.floor(endX / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE);
|
||||
const endChunkY = Math.floor(endY / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE);
|
||||
|
||||
const promises: Promise<PixelChunk | null>[] = [];
|
||||
|
||||
for (let chunkX = startChunkX; chunkX <= endChunkX; chunkX++) {
|
||||
for (let chunkY = startChunkY; chunkY <= endChunkY; chunkY++) {
|
||||
promises.push(this.getChunk(canvasId, chunkX, chunkY));
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
for (const chunk of results) {
|
||||
if (chunk) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
async getCanvasStats(canvasId: string): Promise<{
|
||||
totalPixels: number;
|
||||
lastActivity: number;
|
||||
activeUsers: number;
|
||||
}> {
|
||||
try {
|
||||
const [totalPixels, lastActivity, activeUsers] = await Promise.all([
|
||||
redisClient.get(`${this.keyPrefix}canvas:${canvasId}:totalPixels`),
|
||||
redisClient.get(`${this.keyPrefix}canvas:${canvasId}:lastActivity`),
|
||||
redisClient.sCard(`${this.keyPrefix}canvas:${canvasId}:activeUsers`)
|
||||
]);
|
||||
|
||||
return {
|
||||
totalPixels: parseInt(totalPixels || '0'),
|
||||
lastActivity: parseInt(lastActivity || '0'),
|
||||
activeUsers: activeUsers || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting canvas stats:', error);
|
||||
return {
|
||||
totalPixels: 0,
|
||||
lastActivity: 0,
|
||||
activeUsers: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async addActiveUser(canvasId: string, userId: string): Promise<void> {
|
||||
try {
|
||||
await redisClient.sAdd(`${this.keyPrefix}canvas:${canvasId}:activeUsers`, userId);
|
||||
// Set expiration to auto-remove inactive users
|
||||
await redisClient.expire(`${this.keyPrefix}canvas:${canvasId}:activeUsers`, 300); // 5 minutes
|
||||
} catch (error) {
|
||||
console.error('Error adding active user:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async removeActiveUser(canvasId: string, userId: string): Promise<void> {
|
||||
try {
|
||||
await redisClient.sRem(`${this.keyPrefix}canvas:${canvasId}:activeUsers`, userId);
|
||||
} catch (error) {
|
||||
console.error('Error removing active user:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async getActiveUsers(canvasId: string): Promise<string[]> {
|
||||
try {
|
||||
return await redisClient.sMembers(`${this.keyPrefix}canvas:${canvasId}:activeUsers`);
|
||||
} catch (error) {
|
||||
console.error('Error getting active users:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
91
backend/src/services/RateLimitService.ts
Normal file
91
backend/src/services/RateLimitService.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { redisClient } from '../config/database-factory';
|
||||
import { config } from '../config/env';
|
||||
|
||||
export class RateLimitService {
|
||||
private readonly keyPrefix = config.redis.keyPrefix;
|
||||
|
||||
async checkPixelRateLimit(userId: string): Promise<{ allowed: boolean; resetTime: number }> {
|
||||
const now = Date.now();
|
||||
const minuteKey = `${this.keyPrefix}ratelimit:pixel:${userId}:${Math.floor(now / 60000)}`;
|
||||
const hourKey = `${this.keyPrefix}ratelimit:pixel:${userId}:${Math.floor(now / 3600000)}`;
|
||||
|
||||
try {
|
||||
// Simple approach without pipeline for better compatibility
|
||||
const minuteCount = await redisClient.incr(minuteKey);
|
||||
await redisClient.expire(minuteKey, 60);
|
||||
|
||||
const hourCount = await redisClient.incr(hourKey);
|
||||
await redisClient.expire(hourKey, 3600);
|
||||
|
||||
const minuteExceeded = minuteCount > config.rateLimits.pixelsPerMinute;
|
||||
const hourExceeded = hourCount > config.rateLimits.pixelsPerHour;
|
||||
|
||||
if (minuteExceeded) {
|
||||
return {
|
||||
allowed: false,
|
||||
resetTime: Math.ceil(now / 60000) * 60000
|
||||
};
|
||||
}
|
||||
|
||||
if (hourExceeded) {
|
||||
return {
|
||||
allowed: false,
|
||||
resetTime: Math.ceil(now / 3600000) * 3600000
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true, resetTime: 0 };
|
||||
} catch (error) {
|
||||
console.error('Rate limit check failed:', error);
|
||||
// Fail open - allow the request if Redis is down
|
||||
return { allowed: true, resetTime: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async checkCursorRateLimit(userId: string): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
const secondKey = `${this.keyPrefix}ratelimit:cursor:${userId}:${Math.floor(now / 1000)}`;
|
||||
|
||||
try {
|
||||
const count = await redisClient.incr(secondKey);
|
||||
await redisClient.expire(secondKey, 1);
|
||||
|
||||
return count <= config.rateLimits.cursorUpdatesPerSecond;
|
||||
} catch (error) {
|
||||
console.error('Cursor rate limit check failed:', error);
|
||||
return true; // Fail open
|
||||
}
|
||||
}
|
||||
|
||||
async getUserPixelStats(userId: string): Promise<{
|
||||
totalPixels: number;
|
||||
pixelsThisHour: number;
|
||||
pixelsThisMinute: number;
|
||||
}> {
|
||||
const now = Date.now();
|
||||
const minuteKey = `${this.keyPrefix}ratelimit:pixel:${userId}:${Math.floor(now / 60000)}`;
|
||||
const hourKey = `${this.keyPrefix}ratelimit:pixel:${userId}:${Math.floor(now / 3600000)}`;
|
||||
const totalKey = `${this.keyPrefix}user:${userId}:pixels`;
|
||||
|
||||
try {
|
||||
const [totalPixels, pixelsThisHour, pixelsThisMinute] = await Promise.all([
|
||||
redisClient.get(totalKey),
|
||||
redisClient.get(hourKey),
|
||||
redisClient.get(minuteKey)
|
||||
]);
|
||||
|
||||
return {
|
||||
totalPixels: parseInt(totalPixels || '0'),
|
||||
pixelsThisHour: parseInt(pixelsThisHour || '0'),
|
||||
pixelsThisMinute: parseInt(pixelsThisMinute || '0'),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting user pixel stats:', error);
|
||||
return {
|
||||
totalPixels: 0,
|
||||
pixelsThisHour: 0,
|
||||
pixelsThisMinute: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
202
backend/src/services/UserService.ts
Normal file
202
backend/src/services/UserService.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { pgPool } from '../config/database-factory';
|
||||
import { User } from '@gaplace/shared';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { config } from '../config/env';
|
||||
|
||||
export class UserService {
|
||||
async createGuestUser(): Promise<User> {
|
||||
const client = await pgPool.connect();
|
||||
|
||||
try {
|
||||
const guestId = uuidv4();
|
||||
const username = `Guest_${guestId.slice(0, 8)}`;
|
||||
|
||||
const result = await client.query(`
|
||||
INSERT INTO users (id, username, is_guest)
|
||||
VALUES ($1, $2, true)
|
||||
RETURNING *
|
||||
`, [guestId, username]);
|
||||
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
avatar: row.avatar_url,
|
||||
isGuest: true,
|
||||
createdAt: new Date(row.created_at).getTime(),
|
||||
lastSeen: new Date(row.last_seen).getTime(),
|
||||
};
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async createUser(username: string, email: string, password: string): Promise<User> {
|
||||
const client = await pgPool.connect();
|
||||
|
||||
try {
|
||||
// Check if username or email already exists
|
||||
const existingUser = await client.query(`
|
||||
SELECT id FROM users WHERE username = $1 OR email = $2
|
||||
`, [username, email]);
|
||||
|
||||
if (existingUser.rows.length > 0) {
|
||||
throw new Error('Username or email already exists');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
|
||||
const result = await client.query(`
|
||||
INSERT INTO users (username, email, password_hash, is_guest)
|
||||
VALUES ($1, $2, $3, false)
|
||||
RETURNING *
|
||||
`, [username, email, passwordHash]);
|
||||
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
avatar: row.avatar_url,
|
||||
isGuest: false,
|
||||
createdAt: new Date(row.created_at).getTime(),
|
||||
lastSeen: new Date(row.last_seen).getTime(),
|
||||
};
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async authenticateUser(usernameOrEmail: string, password: string): Promise<User | null> {
|
||||
const client = await pgPool.connect();
|
||||
|
||||
try {
|
||||
const result = await client.query(`
|
||||
SELECT * FROM users
|
||||
WHERE (username = $1 OR email = $1) AND is_guest = false
|
||||
`, [usernameOrEmail]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = result.rows[0];
|
||||
const isValid = await bcrypt.compare(password, row.password_hash);
|
||||
|
||||
if (!isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update last seen
|
||||
await client.query(`
|
||||
UPDATE users SET last_seen = NOW() WHERE id = $1
|
||||
`, [row.id]);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
avatar: row.avatar_url,
|
||||
isGuest: false,
|
||||
createdAt: new Date(row.created_at).getTime(),
|
||||
lastSeen: Date.now(),
|
||||
};
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async getUser(userId: string): Promise<User | null> {
|
||||
const client = await pgPool.connect();
|
||||
|
||||
try {
|
||||
const result = await client.query(`
|
||||
SELECT * FROM users WHERE id = $1
|
||||
`, [userId]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
avatar: row.avatar_url,
|
||||
isGuest: row.is_guest,
|
||||
createdAt: new Date(row.created_at).getTime(),
|
||||
lastSeen: new Date(row.last_seen).getTime(),
|
||||
};
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async updateUserLastSeen(userId: string): Promise<void> {
|
||||
const client = await pgPool.connect();
|
||||
|
||||
try {
|
||||
await client.query(`
|
||||
UPDATE users SET last_seen = NOW() WHERE id = $1
|
||||
`, [userId]);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
generateJWT(user: User): string {
|
||||
return jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
isGuest: user.isGuest
|
||||
},
|
||||
config.jwtSecret,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
}
|
||||
|
||||
verifyJWT(token: string): { userId: string; username: string; isGuest: boolean } | null {
|
||||
try {
|
||||
return jwt.verify(token, config.jwtSecret) as any;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserStats(userId: string): Promise<{
|
||||
totalPixels: number;
|
||||
joinedCanvases: number;
|
||||
accountAge: number;
|
||||
}> {
|
||||
const client = await pgPool.connect();
|
||||
|
||||
try {
|
||||
const [userResult, canvasResult] = await Promise.all([
|
||||
client.query(`
|
||||
SELECT created_at FROM users WHERE id = $1
|
||||
`, [userId]),
|
||||
client.query(`
|
||||
SELECT COUNT(DISTINCT canvas_id) as canvas_count
|
||||
FROM user_sessions WHERE user_id = $1
|
||||
`, [userId])
|
||||
]);
|
||||
|
||||
const user = userResult.rows[0];
|
||||
const canvasCount = canvasResult.rows[0]?.canvas_count || 0;
|
||||
|
||||
return {
|
||||
totalPixels: 0, // Will be fetched from Redis via RateLimitService
|
||||
joinedCanvases: parseInt(canvasCount),
|
||||
accountAge: user ? Date.now() - new Date(user.created_at).getTime() : 0,
|
||||
};
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
267
backend/src/services/WebSocketService.ts
Normal file
267
backend/src/services/WebSocketService.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
import { Server as SocketIOServer, Socket } from 'socket.io';
|
||||
import { Server as HTTPServer } from 'http';
|
||||
import {
|
||||
WebSocketMessage,
|
||||
MessageType,
|
||||
PlacePixelMessage,
|
||||
LoadChunkMessage,
|
||||
CursorMoveMessage,
|
||||
UserPresence,
|
||||
isValidColor
|
||||
} from '@gaplace/shared';
|
||||
import { CanvasService } from './CanvasService';
|
||||
import { RateLimitService } from './RateLimitService';
|
||||
import { UserService } from './UserService';
|
||||
import { config } from '../config/env';
|
||||
|
||||
export class WebSocketService {
|
||||
private io: SocketIOServer;
|
||||
private canvasService: CanvasService;
|
||||
private rateLimitService: RateLimitService;
|
||||
private userService: UserService;
|
||||
private userPresence = new Map<string, UserPresence>();
|
||||
|
||||
constructor(server: HTTPServer) {
|
||||
this.io = new SocketIOServer(server, {
|
||||
cors: {
|
||||
origin: config.corsOrigin,
|
||||
methods: ['GET', 'POST'],
|
||||
credentials: true
|
||||
},
|
||||
// Enable binary support for better performance
|
||||
parser: undefined, // Use default parser for now, can optimize later
|
||||
transports: ['polling', 'websocket'], // Start with polling for better compatibility
|
||||
allowEIO3: true, // Allow Engine.IO v3 compatibility
|
||||
pingTimeout: 60000,
|
||||
pingInterval: 25000,
|
||||
});
|
||||
|
||||
this.canvasService = new CanvasService();
|
||||
this.rateLimitService = new RateLimitService();
|
||||
this.userService = new UserService();
|
||||
|
||||
this.setupEventHandlers();
|
||||
}
|
||||
|
||||
private setupEventHandlers(): void {
|
||||
this.io.on('connection', (socket: Socket) => {
|
||||
console.log(`User connected: ${socket.id}`);
|
||||
|
||||
// Handle user authentication/identification
|
||||
socket.on('auth', async (data: { userId?: string; canvasId: string; username?: string }) => {
|
||||
try {
|
||||
let userId = data.userId;
|
||||
|
||||
// Create guest user if no userId provided
|
||||
if (!userId) {
|
||||
const guestUser = await this.userService.createGuestUser();
|
||||
userId = guestUser.id;
|
||||
}
|
||||
|
||||
// Store user info in socket
|
||||
socket.data.userId = userId;
|
||||
socket.data.canvasId = data.canvasId;
|
||||
socket.data.username = data.username || `Guest-${userId?.slice(-4)}`;
|
||||
|
||||
// Join canvas room
|
||||
await socket.join(`canvas:${data.canvasId}`);
|
||||
|
||||
// Add to active users
|
||||
await this.canvasService.addActiveUser(data.canvasId, userId);
|
||||
|
||||
// Send canvas info
|
||||
const stats = await this.canvasService.getCanvasStats(data.canvasId);
|
||||
socket.emit('canvas_info', stats);
|
||||
|
||||
// Send current user list
|
||||
const activeUsers = await this.canvasService.getActiveUsers(data.canvasId);
|
||||
this.io.to(`canvas:${data.canvasId}`).emit('user_list', activeUsers);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Auth error:', error);
|
||||
socket.emit('error', { message: 'Authentication failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Handle pixel placement
|
||||
socket.on('place_pixel', async (message: PlacePixelMessage) => {
|
||||
await this.handlePlacePixel(socket, message);
|
||||
});
|
||||
|
||||
// Handle chunk loading
|
||||
socket.on('load_chunk', async (message: LoadChunkMessage) => {
|
||||
await this.handleLoadChunk(socket, message);
|
||||
});
|
||||
|
||||
// Handle cursor movement
|
||||
socket.on('cursor_move', async (message: CursorMoveMessage) => {
|
||||
await this.handleCursorMove(socket, message);
|
||||
});
|
||||
|
||||
// Handle disconnect
|
||||
socket.on('disconnect', async () => {
|
||||
console.log(`User disconnected: ${socket.id}`);
|
||||
|
||||
if (socket.data.userId && socket.data.canvasId) {
|
||||
await this.canvasService.removeActiveUser(socket.data.canvasId, socket.data.userId);
|
||||
|
||||
// Remove from presence tracking
|
||||
this.userPresence.delete(socket.data.userId);
|
||||
|
||||
// Notify others
|
||||
const activeUsers = await this.canvasService.getActiveUsers(socket.data.canvasId);
|
||||
this.io.to(`canvas:${socket.data.canvasId}`).emit('user_list', activeUsers);
|
||||
}
|
||||
});
|
||||
|
||||
// Heartbeat for connection monitoring
|
||||
socket.on('heartbeat', () => {
|
||||
socket.emit('heartbeat_ack', { timestamp: Date.now() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async handlePlacePixel(socket: Socket, message: PlacePixelMessage): Promise<void> {
|
||||
const { userId, canvasId } = socket.data;
|
||||
|
||||
if (!userId || !canvasId) {
|
||||
socket.emit('error', { message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate input
|
||||
if (!isValidColor(message.color)) {
|
||||
socket.emit('error', { message: 'Invalid color format' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
const rateLimit = await this.rateLimitService.checkPixelRateLimit(userId);
|
||||
if (!rateLimit.allowed) {
|
||||
socket.emit('rate_limited', {
|
||||
message: 'Rate limit exceeded',
|
||||
resetTime: rateLimit.resetTime
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Place pixel
|
||||
const success = await this.canvasService.placePixel(
|
||||
canvasId,
|
||||
message.x,
|
||||
message.y,
|
||||
message.color,
|
||||
userId
|
||||
);
|
||||
|
||||
if (success) {
|
||||
// Broadcast to all users in the canvas
|
||||
this.io.to(`canvas:${canvasId}`).emit('pixel_placed', {
|
||||
type: MessageType.PIXEL_PLACED,
|
||||
x: message.x,
|
||||
y: message.y,
|
||||
color: message.color,
|
||||
userId,
|
||||
username: socket.data.username,
|
||||
canvasId,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Send updated stats
|
||||
const stats = await this.canvasService.getCanvasStats(canvasId);
|
||||
this.io.to(`canvas:${canvasId}`).emit('canvas_updated', stats);
|
||||
} else {
|
||||
socket.emit('error', { message: 'Failed to place pixel' });
|
||||
}
|
||||
}
|
||||
|
||||
private async handleLoadChunk(socket: Socket, message: LoadChunkMessage): Promise<void> {
|
||||
const { canvasId } = socket.data;
|
||||
|
||||
if (!canvasId) {
|
||||
socket.emit('error', { message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const chunk = await this.canvasService.getChunk(canvasId, message.chunkX, message.chunkY);
|
||||
|
||||
if (chunk) {
|
||||
const pixels = Array.from(chunk.pixels.entries()).map(([key, color]) => {
|
||||
const [localX, localY] = key.split(',').map(Number);
|
||||
return {
|
||||
x: message.chunkX * 64 + localX,
|
||||
y: message.chunkY * 64 + localY,
|
||||
color
|
||||
};
|
||||
});
|
||||
|
||||
socket.emit('chunk_data', {
|
||||
type: MessageType.CHUNK_DATA,
|
||||
chunkX: message.chunkX,
|
||||
chunkY: message.chunkY,
|
||||
pixels,
|
||||
canvasId,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} else {
|
||||
// Send empty chunk
|
||||
socket.emit('chunk_data', {
|
||||
type: MessageType.CHUNK_DATA,
|
||||
chunkX: message.chunkX,
|
||||
chunkY: message.chunkY,
|
||||
pixels: [],
|
||||
canvasId,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading chunk:', error);
|
||||
socket.emit('error', { message: 'Failed to load chunk' });
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCursorMove(socket: Socket, message: CursorMoveMessage): Promise<void> {
|
||||
const { userId, canvasId } = socket.data;
|
||||
|
||||
if (!userId || !canvasId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
const allowed = await this.rateLimitService.checkCursorRateLimit(userId);
|
||||
if (!allowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update presence
|
||||
const user = await this.userService.getUser(userId);
|
||||
if (user) {
|
||||
this.userPresence.set(userId, {
|
||||
userId,
|
||||
username: user.username,
|
||||
cursor: { x: message.x, y: message.y },
|
||||
color: '#ff0000', // TODO: Get user's selected color
|
||||
tool: message.tool,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
// Broadcast cursor position to others in the canvas (excluding sender)
|
||||
socket.to(`canvas:${canvasId}`).emit('cursor_update', {
|
||||
userId,
|
||||
username: user.username,
|
||||
x: message.x,
|
||||
y: message.y,
|
||||
tool: message.tool
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public getIO(): SocketIOServer {
|
||||
return this.io;
|
||||
}
|
||||
|
||||
public async broadcastToCanvas(canvasId: string, event: string, data: any): Promise<void> {
|
||||
this.io.to(`canvas:${canvasId}`).emit(event, data);
|
||||
}
|
||||
}
|
||||
23
backend/tsconfig.json
Normal file
23
backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"resolveJsonModule": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue