DEV Community

Safal Bhandari
Safal Bhandari

Posted on

Dump1

Week 0 Setup — Typeracer Backend

Goal: Remove blockers, set up dev environment, and learn minimal background.

Timeline: 2–4 days | 8–12 hours

Acceptance Criteria: WS connect/disconnect works. Redis GET/SET works.


Table of Contents

  1. Prerequisites
  2. Environment Setup
  3. Project Structure
  4. Docker & Docker Compose
  5. WebSocket Echo Server
  6. Redis Client & Basic Operations
  7. CI/CD Pipeline (GitHub Actions)
  8. Testing
  9. Running the Project
  10. Acceptance Checklist

Prerequisites

Technologies to Install

  1. Node.js (v18+, recommended v20 LTS)

  2. TypeScript (via npm)

    • Will be installed as dev dependency
  3. Docker & Docker Compose

  4. Git

  5. pnpm (optional but recommended)

    • Install: npm install -g pnpm
    • Verify: pnpm --version

Background Knowledge

  • Basic HTTP & REST concepts
  • WebSocket basics (connection, message passing, disconnect)
  • Basic Redis concepts: keys, strings, hashes, TTLs, pub/sub
  • Git fundamentals (clone, commit, push)
  • Docker basics (Dockerfile, docker-compose)

Environment Setup

1. Clone & Initialize Repository

# Clone the repo
git clone https://github.com/SafalBhandari12/typeracer-backend.git
cd typeracer-backend

# Initialize git (if not cloned)
git init
git config user.name "Your Name"
git config user.email "your.email@example.com"
Enter fullscreen mode Exit fullscreen mode

2. Install Dependencies

First, create a package.json file in the root directory with all required dependencies:

{
  "name": "typeracer-backend",
  "version": "1.0.0",
  "description": "Real-time multiplayer typing race backend with WebSocket and Redis",
  "main": "dist/server.js",
  "type": "module",
  "scripts": {
    "dev": "tsx --watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "lint": "eslint src tests --ext .ts",
    "lint:fix": "eslint src tests --ext .ts --fix",
    "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
    "clean": "rimraf dist",
    "setup": "pnpm install && pnpm run build",
    "setup:deps": "pnpm install --frozen-lockfile",
    "setup:dev": "pnpm install && pnpm run build && pnpm run test",
    "setup:fresh": "pnpm store prune && pnpm install --frozen-lockfile && pnpm run build",
    "docker:build": "docker build -t typeracer-backend .",
    "docker:run": "docker run -p 3000:3000 -p 8000:8000 typeracer-backend",
    "compose:up": "docker-compose up --build",
    "compose:down": "docker-compose down",
    "redis:start": "redis-server",
    "ws:test": "node scripts/test-ws.js"
  },
  "dependencies": {
    "dotenv": "^16.3.1",
    "redis": "^4.6.10",
    "ws": "^8.14.2"
  },
  "devDependencies": {
    "@eslint/js": "^9.9.1",
    "@jest/globals": "^29.7.0",
    "@types/jest": "^29.5.5",
    "@types/node": "^20.8.0",
    "@types/ws": "^8.5.8",
    "eslint": "^9.9.1",
    "jest": "^29.7.0",
    "prettier": "^3.0.3",
    "rimraf": "^5.0.5",
    "ts-jest": "^29.1.1",
    "tsx": "^3.14.0",
    "typescript": "^5.2.2",
    "typescript-eslint": "^8.4.0"
  },
  "engines": {
    "node": ">=18.0.0"
  },
  "keywords": [
    "typeracer",
    "websocket",
    "redis",
    "typescript",
    "realtime",
    "multiplayer"
  ],
  "author": "Your Name",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/SafalBhandari12/typeracer-backend.git"
  }
}
Enter fullscreen mode Exit fullscreen mode

Then install the dependencies:

# Using pnpm (recommended) - basic installation
pnpm install

# OR using npm
npm install

# Alternative pnpm setup scripts:
# Install dependencies only (frozen lockfile for CI/production)
pnpm run setup:deps

# Full development setup (install + build + test)
pnpm run setup:dev

# Fresh installation (clear cache + install + build)
pnpm run setup:fresh

# Quick setup (install + build)
pnpm run setup
Enter fullscreen mode Exit fullscreen mode

3. Create Environment File

Create a .env file in the root directory:

# Server
NODE_ENV=development
PORT=3000
SERVER_TIMEOUT=30000

# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_DB=0
REDIS_PASSWORD=

# WebSocket
WS_PORT=8000
WS_HEARTBEAT_INTERVAL=30000

# Logging
LOG_LEVEL=debug
Enter fullscreen mode Exit fullscreen mode

For Docker environments, create .env.docker:

NODE_ENV=development
PORT=3000
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_DB=0
LOG_LEVEL=debug
Enter fullscreen mode Exit fullscreen mode

Project Structure

typeracer-backend/
├── src/
│   ├── server.ts                 # Main server entry point
│   ├── ws/
│   │   ├── server.ts             # WebSocket server setup
│   │   └── handlers.ts           # WS message handlers
│   ├── redis/
│   │   ├── client.ts             # Redis client singleton
│   │   └── operations.ts         # Basic Redis operations
│   ├── utils/
│   │   ├── logger.ts             # Logging utility
│   │   └── validators.ts         # Input validation
│   └── types/
│       └── index.ts              # TypeScript interfaces
├── tests/
│   ├── unit/
│   │   ├── redis.test.ts
│   │   └── ws.test.ts
│   └── integration/
│       └── echo.test.ts
├── .github/
│   └── workflows/
│       ├── ci.yml                # Main CI pipeline
│       └── docker.yml            # Docker build & push
├── docker-compose.yml            # Local development setup
├── Dockerfile                    # Production build
├── tsconfig.json                 # TypeScript config
├── eslint.config.js              # ESLint config
├── package.json                  # Dependencies
├── .gitignore                    # Git ignore file
├── jest.config.js                # Jest config
├── WEEK_0_SETUP.md              # This file
├── README.md                     # Project overview
└── CHANGELOG.md                  # Version history
Enter fullscreen mode Exit fullscreen mode

Docker & Docker Compose

Dockerfile (Production Multi-Stage Build)

# Build stage
FROM node:20-alpine AS build
WORKDIR /app

# Install dependencies
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install

# Copy source and build
COPY . .
RUN pnpm run build

# Production stage
FROM node:20-alpine AS runtime
WORKDIR /app

# Install production dependencies only
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --prod --frozen-lockfile

# Copy compiled code from build stage
COPY --from=build /app/dist ./dist

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"

EXPOSE 3000
CMD ["node", "dist/server.js"]
Enter fullscreen mode Exit fullscreen mode

docker-compose.yml (Development)

version: "3.9"

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: typeracer-api
    ports:
      - "3000:3000"
      - "8000:8000"
    environment:
      - NODE_ENV=development
      - REDIS_HOST=redis
      - REDIS_PORT=6379
      - REDIS_DB=0
      - LOG_LEVEL=debug
      - WS_PORT=8000
    depends_on:
      redis:
        condition: service_healthy
    volumes:
      - ./src:/app/src
      - /app/node_modules
    networks:
      - typeracer-network
    command: pnpm run dev

  redis:
    image: redis:7-alpine
    container_name: typeracer-redis
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - typeracer-network
    command: redis-server --appendonly yes

  redis-cli:
    image: redis:7-alpine
    container_name: typeracer-redis-cli
    depends_on:
      - redis
    networks:
      - typeracer-network
    profiles:
      - debug
    command: redis-cli -h redis ping

volumes:
  redis-data:

networks:
  typeracer-network:
    driver: bridge
Enter fullscreen mode Exit fullscreen mode

WebSocket Echo Server

src/server.ts (Main Entry Point)

import dotenv from "dotenv";
import http from "http";
import { createWebSocketServer } from "./ws/server.js";
import { initializeRedisClient } from "./redis/client.js";
import { logger } from "./utils/logger.js";

dotenv.config();

const PORT = parseInt(process.env.PORT || "3000", 10);
const WS_PORT = parseInt(process.env.WS_PORT || "8000", 10);

async function bootstrap() {
  try {
    // Initialize Redis
    logger.info("Initializing Redis client...");
    const redisClient = await initializeRedisClient();
    logger.info("Redis connected successfully");

    // Create HTTP server
    const httpServer = http.createServer((_req, res) => {
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ status: "ok" }));
    });

    // Create WebSocket server
    logger.info(`Starting WebSocket server on port ${WS_PORT}`);
    const wsServer = createWebSocketServer(WS_PORT, redisClient);

    // Start HTTP server
    httpServer.listen(PORT, () => {
      logger.info(`🚀 HTTP server listening on port ${PORT}`);
      logger.info(`🚀 WebSocket server available at ws://localhost:${WS_PORT}`);
    });

    // Graceful shutdown
    process.on("SIGTERM", async () => {
      logger.info("SIGTERM received, shutting down gracefully...");
      httpServer.close();
      wsServer.close();
      await redisClient.quit();
      process.exit(0);
    });
  } catch (error) {
    logger.error("Bootstrap error:", error);
    process.exit(1);
  }
}

bootstrap();
Enter fullscreen mode Exit fullscreen mode

src/ws/server.ts (WebSocket Server)

import WebSocket from "ws";
import { RedisClientType } from "redis";
import { logger } from "../utils/logger.js";
import { handleMessage } from "./handlers.js";

interface ExtendedWebSocket extends WebSocket {
  id: string;
  isAlive: boolean;
}

export function createWebSocketServer(
  port: number,
  redisClient: RedisClientType
): WebSocket.Server {
  const wss = new WebSocket.Server({ port });

  logger.info(`WebSocket server listening on port ${port}`);

  wss.on("connection", (ws: ExtendedWebSocket) => {
    ws.id = generateClientId();
    ws.isAlive = true;

    logger.info(`Client connected: ${ws.id}`);

    // Heartbeat mechanism
    ws.on("pong", () => {
      ws.isAlive = true;
    });

    // Handle incoming messages
    ws.on("message", async (data: WebSocket.RawData) => {
      try {
        await handleMessage(ws, data, redisClient);
      } catch (error) {
        logger.error(`Message handling error for ${ws.id}:`, error);
        ws.send(
          JSON.stringify({
            type: "error",
            message: "Internal server error",
          })
        );
      }
    });

    // Handle client disconnect
    ws.on("close", async () => {
      logger.info(`Client disconnected: ${ws.id}`);
      // Cleanup in Redis
      await redisClient.del(`session:${ws.id}`);
    });

    // Handle errors
    ws.on("error", (error) => {
      logger.error(`WebSocket error for ${ws.id}:`, error);
    });

    // Send welcome message
    ws.send(
      JSON.stringify({
        type: "welcome",
        clientId: ws.id,
        timestamp: new Date().toISOString(),
      })
    );
  });

  // Heartbeat interval
  const heartbeatInterval = setInterval(() => {
    wss.clients.forEach((ws: ExtendedWebSocket) => {
      if (ws.isAlive === false) {
        return ws.terminate();
      }
      ws.isAlive = false;
      ws.ping();
    });
  }, 30000);

  wss.on("close", () => {
    clearInterval(heartbeatInterval);
  });

  return wss;
}

function generateClientId(): string {
  return `client_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
Enter fullscreen mode Exit fullscreen mode

src/ws/handlers.ts (Message Handlers)

import WebSocket from "ws";
import { RedisClientType } from "redis";
import { logger } from "../utils/logger.js";

interface ExtendedWebSocket extends WebSocket {
  id: string;
}

interface IncomingMessage {
  type: "echo" | "ping" | "join" | "player_progress";
  payload?: Record<string, unknown>;
}

export async function handleMessage(
  ws: ExtendedWebSocket,
  data: WebSocket.RawData,
  redisClient: RedisClientType
): Promise<void> {
  try {
    const message: IncomingMessage = JSON.parse(data.toString());

    logger.debug(`Message from ${ws.id}:`, message);

    switch (message.type) {
      case "echo":
        // Echo server: return what client sends
        ws.send(
          JSON.stringify({
            type: "echo_response",
            payload: message.payload,
            clientId: ws.id,
            timestamp: new Date().toISOString(),
          })
        );
        break;

      case "ping":
        ws.send(
          JSON.stringify({
            type: "pong",
            clientId: ws.id,
            timestamp: new Date().toISOString(),
          })
        );
        break;

      case "join":
        // Store player info in Redis
        const playerId = message.payload?.playerId || ws.id;
        await redisClient.hSet(`player:${playerId}`, {
          clientId: ws.id,
          joinedAt: new Date().toISOString(),
          status: "active",
        });

        ws.send(
          JSON.stringify({
            type: "join_ack",
            playerId,
            clientId: ws.id,
            status: "joined",
            timestamp: new Date().toISOString(),
          })
        );

        logger.info(`Player joined: ${playerId}`);
        break;

      case "player_progress":
        // Handle player progress (stub for Week 0)
        const pid = message.payload?.playerId;
        const progress = message.payload?.progress;

        if (pid && progress !== undefined) {
          await redisClient.hSet(`player:${pid}`, {
            progress,
            lastUpdate: new Date().toISOString(),
          });

          ws.send(
            JSON.stringify({
              type: "progress_ack",
              playerId: pid,
              progress,
              timestamp: new Date().toISOString(),
            })
          );
        }
        break;

      default:
        logger.warn(`Unknown message type: ${message.type}`);
        ws.send(
          JSON.stringify({
            type: "error",
            message: `Unknown message type: ${message.type}`,
          })
        );
    }
  } catch (error) {
    logger.error("Failed to parse message:", error);
    ws.send(
      JSON.stringify({
        type: "error",
        message: "Invalid message format",
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Redis Client & Basic Operations

src/redis/client.ts (Redis Client Singleton)

import { createClient, RedisClientType } from "redis";
import { logger } from "../utils/logger.js";

let redisClient: RedisClientType | null = null;

export async function initializeRedisClient(): Promise<RedisClientType> {
  if (redisClient) {
    return redisClient;
  }

  const host = process.env.REDIS_HOST || "localhost";
  const port = parseInt(process.env.REDIS_PORT || "6379", 10);
  const password = process.env.REDIS_PASSWORD;

  redisClient = createClient({
    socket: {
      host,
      port,
      reconnectStrategy: (retries) => {
        const delay = Math.min(retries * 50, 500);
        return delay;
      },
    },
    password,
    commandsQueueBehavior: "block",
  });

  redisClient.on("error", (err) => {
    logger.error("Redis client error:", err);
  });

  redisClient.on("connect", () => {
    logger.info("Redis client connected");
  });

  redisClient.on("ready", () => {
    logger.info("Redis client ready");
  });

  redisClient.on("reconnecting", () => {
    logger.warn("Redis client reconnecting...");
  });

  await redisClient.connect();
  return redisClient;
}

export function getRedisClient(): RedisClientType {
  if (!redisClient) {
    throw new Error(
      "Redis client not initialized. Call initializeRedisClient first."
    );
  }
  return redisClient;
}
Enter fullscreen mode Exit fullscreen mode

src/redis/operations.ts (Basic Redis Operations)

import { RedisClientType } from "redis";
import { logger } from "../utils/logger.js";

/**
 * Basic Redis operations for Week 0
 */

export class RedisOperations {
  constructor(private client: RedisClientType) {}

  /**
   * String operations
   */
  async set(key: string, value: string, ttl?: number): Promise<void> {
    try {
      if (ttl) {
        await this.client.setEx(key, ttl, value);
      } else {
        await this.client.set(key, value);
      }
      logger.debug(`SET ${key}`);
    } catch (error) {
      logger.error(`SET error for key ${key}:`, error);
      throw error;
    }
  }

  async get(key: string): Promise<string | null> {
    try {
      const value = await this.client.get(key);
      logger.debug(`GET ${key} = ${value}`);
      return value;
    } catch (error) {
      logger.error(`GET error for key ${key}:`, error);
      throw error;
    }
  }

  /**
   * Hash operations
   */
  async hSet(key: string, field: string, value: string): Promise<number> {
    try {
      const result = await this.client.hSet(key, field, value);
      logger.debug(`HSET ${key}:${field}`);
      return result;
    } catch (error) {
      logger.error(`HSET error for key ${key}:`, error);
      throw error;
    }
  }

  async hGet(key: string, field: string): Promise<string | null> {
    try {
      const value = await this.client.hGet(key, field);
      logger.debug(`HGET ${key}:${field} = ${value}`);
      return value;
    } catch (error) {
      logger.error(`HGET error for key ${key}:`, error);
      throw error;
    }
  }

  async hGetAll(key: string): Promise<Record<string, string>> {
    try {
      const value = await this.client.hGetAll(key);
      logger.debug(`HGETALL ${key}`);
      return value;
    } catch (error) {
      logger.error(`HGETALL error for key ${key}:`, error);
      throw error;
    }
  }

  /**
   * Sorted set operations
   */
  async zAdd(
    key: string,
    member: { score: number; value: string }
  ): Promise<number> {
    try {
      const result = await this.client.zAdd(key, member);
      logger.debug(`ZADD ${key} ${member.value}:${member.score}`);
      return result;
    } catch (error) {
      logger.error(`ZADD error for key ${key}:`, error);
      throw error;
    }
  }

  async zRange(
    key: string,
    start: number = 0,
    stop: number = -1,
    options?: { REV?: boolean; BYSCORE?: boolean }
  ): Promise<string[]> {
    try {
      const value = await this.client.zRange(key, start, stop, options);
      logger.debug(`ZRANGE ${key} ${start}:${stop}`);
      return value;
    } catch (error) {
      logger.error(`ZRANGE error for key ${key}:`, error);
      throw error;
    }
  }

  /**
   * Key operations
   */
  async del(key: string): Promise<number> {
    try {
      const result = await this.client.del(key);
      logger.debug(`DEL ${key}`);
      return result;
    } catch (error) {
      logger.error(`DEL error for key ${key}:`, error);
      throw error;
    }
  }

  async exists(key: string): Promise<boolean> {
    try {
      const result = await this.client.exists(key);
      logger.debug(`EXISTS ${key} = ${result}`);
      return result === 1;
    } catch (error) {
      logger.error(`EXISTS error for key ${key}:`, error);
      throw error;
    }
  }

  async expire(key: string, ttl: number): Promise<boolean> {
    try {
      const result = await this.client.expire(key, ttl);
      logger.debug(`EXPIRE ${key} ${ttl}s`);
      return result === 1;
    } catch (error) {
      logger.error(`EXPIRE error for key ${key}:`, error);
      throw error;
    }
  }

  /**
   * Pub/Sub operations
   */
  async publish(channel: string, message: string): Promise<number> {
    try {
      const result = await this.client.publish(channel, message);
      logger.debug(`PUBLISH ${channel}`);
      return result;
    } catch (error) {
      logger.error(`PUBLISH error for channel ${channel}:`, error);
      throw error;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Utilities

src/utils/logger.ts (Logging Utility)

type LogLevel = "debug" | "info" | "warn" | "error";

const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
  debug: 0,
  info: 1,
  warn: 2,
  error: 3,
};

const currentLogLevel: LogLevel = (process.env.LOG_LEVEL || "info") as LogLevel;

function formatTimestamp(): string {
  return new Date().toISOString();
}

function shouldLog(level: LogLevel): boolean {
  return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[currentLogLevel];
}

export const logger = {
  debug: (message: string, data?: unknown) => {
    if (shouldLog("debug")) {
      console.log(`[${formatTimestamp()}] [DEBUG] ${message}`, data || "");
    }
  },

  info: (message: string, data?: unknown) => {
    if (shouldLog("info")) {
      console.log(`[${formatTimestamp()}] [INFO] ${message}`, data || "");
    }
  },

  warn: (message: string, data?: unknown) => {
    if (shouldLog("warn")) {
      console.warn(`[${formatTimestamp()}] [WARN] ${message}`, data || "");
    }
  },

  error: (message: string, error?: unknown) => {
    if (shouldLog("error")) {
      console.error(`[${formatTimestamp()}] [ERROR] ${message}`, error || "");
    }
  },
};
Enter fullscreen mode Exit fullscreen mode

src/utils/validators.ts (Input Validation)

export interface ValidationResult {
  isValid: boolean;
  errors: string[];
}

export function validatePlayerId(id: string): ValidationResult {
  const errors: string[] = [];

  if (!id || typeof id !== "string") {
    errors.push("playerId must be a non-empty string");
  }

  if (id.length > 100) {
    errors.push("playerId must be less than 100 characters");
  }

  return {
    isValid: errors.length === 0,
    errors,
  };
}

export function validateWebSocketMessage(message: unknown): ValidationResult {
  const errors: string[] = [];

  if (typeof message !== "object" || message === null) {
    errors.push("Message must be a JSON object");
  }

  const msg = message as Record<string, unknown>;
  if (!msg.type || typeof msg.type !== "string") {
    errors.push("Message must have a type field");
  }

  return {
    isValid: errors.length === 0,
    errors,
  };
}
Enter fullscreen mode Exit fullscreen mode

src/types/index.ts (TypeScript Types)

export interface Player {
  id: string;
  clientId: string;
  joinedAt: string;
  status: "active" | "inactive" | "completed";
  progress?: number;
  lastUpdate?: string;
}

export interface WebSocketMessage {
  type: "echo" | "ping" | "join" | "player_progress" | "welcome" | "error";
  payload?: Record<string, unknown>;
  clientId?: string;
  timestamp?: string;
}

export interface Race {
  id: string;
  createdAt: string;
  status: "waiting" | "starting" | "running" | "finished";
  players: Player[];
}
Enter fullscreen mode Exit fullscreen mode

CI/CD Pipeline (GitHub Actions)

.github/workflows/ci.yml (Main CI Pipeline)

name: CI - Lint, Test, Build

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

jobs:
  lint-and-test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [18.x, 20.x]

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}

      - name: Setup pnpm
        uses: pnpm/action-setup@v2
        with:
          version: latest

      - name: Get pnpm store directory
        shell: bash
        run: |
          echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV

      - name: Cache dependencies
        uses: actions/cache@v3
        with:
          path: ${{ env.STORE_PATH }}
          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
          restore-keys: |
            ${{ runner.os }}-pnpm-store-

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Lint code
        run: pnpm run lint

      - name: Build TypeScript
        run: pnpm run build

      - name: Run tests
        run: pnpm run test

  docker-build:
    needs: lint-and-test
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: false
          tags: typeracer-backend:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  security-scan:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: "fs"
          scan-ref: "."
          format: "sarif"
          output: "trivy-results.sarif"

      - name: Upload Trivy results to GitHub Security tab
        uses: github/codeql-action/upload-sarif@v2
        if: always()
        with:
          sarif_file: "trivy-results.sarif"
Enter fullscreen mode Exit fullscreen mode

.github/workflows/docker.yml (Docker Build & Push)

name: Docker Build & Push

on:
  push:
    branches: [main]
    tags: ["v*"]
  workflow_dispatch:

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to Container Registry
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
Enter fullscreen mode Exit fullscreen mode

Testing

tests/unit/redis.test.ts (Redis Unit Tests)

import { describe, it, expect, beforeAll, afterAll } from "@jest/globals";
import { initializeRedisClient } from "../../src/redis/client";
import { RedisOperations } from "../../src/redis/operations";
import { RedisClientType } from "redis";

describe("Redis Operations", () => {
  let redisClient: RedisClientType;
  let ops: RedisOperations;

  beforeAll(async () => {
    redisClient = await initializeRedisClient();
    ops = new RedisOperations(redisClient);
  });

  afterAll(async () => {
    await redisClient.quit();
  });

  describe("String Operations", () => {
    it("should set and get a string value", async () => {
      await ops.set("test:key", "test:value");
      const value = await ops.get("test:key");
      expect(value).toBe("test:value");
    });

    it("should set string with TTL", async () => {
      await ops.set("test:ttl", "value", 10);
      const value = await ops.get("test:ttl");
      expect(value).toBe("value");
    });
  });

  describe("Hash Operations", () => {
    it("should set and get hash field", async () => {
      await ops.hSet("player:1", "name", "Alice");
      const value = await ops.hGet("player:1", "name");
      expect(value).toBe("Alice");
    });

    it("should get all hash fields", async () => {
      await ops.hSet("player:2", "name", "Bob");
      await ops.hSet("player:2", "status", "active");
      const all = await ops.hGetAll("player:2");
      expect(all.name).toBe("Bob");
      expect(all.status).toBe("active");
    });
  });

  describe("Key Operations", () => {
    it("should delete a key", async () => {
      await ops.set("temp:key", "value");
      const deleted = await ops.del("temp:key");
      expect(deleted).toBe(1);
    });

    it("should check if key exists", async () => {
      await ops.set("exists:key", "value");
      const exists = await ops.exists("exists:key");
      expect(exists).toBe(true);
    });

    it("should set expiration on key", async () => {
      await ops.set("expire:key", "value");
      const result = await ops.expire("expire:key", 5);
      expect(result).toBe(true);
    });
  });

  describe("Sorted Set Operations", () => {
    it("should add to sorted set", async () => {
      const result = await ops.zAdd("leaderboard", {
        score: 100,
        value: "Alice",
      });
      expect(result).toBe(1);
    });

    it("should retrieve sorted set range", async () => {
      await ops.zAdd("scores", { score: 10, value: "first" });
      await ops.zAdd("scores", { score: 20, value: "second" });
      const range = await ops.zRange("scores", 0, -1);
      expect(range.length).toBe(2);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

tests/unit/ws.test.ts (WebSocket Unit Tests)

import { describe, it, expect } from "@jest/globals";
import {
  validatePlayerId,
  validateWebSocketMessage,
} from "../../src/utils/validators";

describe("WebSocket Validators", () => {
  describe("validatePlayerId", () => {
    it("should accept valid player ID", () => {
      const result = validatePlayerId("player_123");
      expect(result.isValid).toBe(true);
      expect(result.errors).toHaveLength(0);
    });

    it("should reject empty player ID", () => {
      const result = validatePlayerId("");
      expect(result.isValid).toBe(false);
      expect(result.errors.length).toBeGreaterThan(0);
    });

    it("should reject player ID exceeding 100 chars", () => {
      const longId = "a".repeat(101);
      const result = validatePlayerId(longId);
      expect(result.isValid).toBe(false);
    });
  });

  describe("validateWebSocketMessage", () => {
    it("should accept valid message", () => {
      const result = validateWebSocketMessage({ type: "ping" });
      expect(result.isValid).toBe(true);
    });

    it("should reject non-object message", () => {
      const result = validateWebSocketMessage("invalid");
      expect(result.isValid).toBe(false);
    });

    it("should reject message without type", () => {
      const result = validateWebSocketMessage({ payload: {} });
      expect(result.isValid).toBe(false);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

tests/integration/echo.test.ts (Integration Tests)

import { describe, it, expect, beforeAll, afterAll } from "@jest/globals";
import WebSocket from "ws";
import { createWebSocketServer } from "../../src/ws/server";
import { initializeRedisClient } from "../../src/redis/client";
import { RedisClientType } from "redis";

describe("WebSocket Echo Server Integration", () => {
  let wss: WebSocket.Server;
  let redisClient: RedisClientType;
  const WS_URL = "ws://localhost:9001";

  beforeAll(async () => {
    redisClient = await initializeRedisClient();
    wss = createWebSocketServer(9001, redisClient);

    // Wait for server to start
    await new Promise((resolve) => setTimeout(resolve, 100));
  });

  afterAll(async () => {
    wss.close();
    await redisClient.quit();
  });

  it("should connect and receive welcome message", async () => {
    return new Promise<void>((resolve, reject) => {
      const ws = new WebSocket(WS_URL);

      ws.on("open", () => {
        // Connected
      });

      ws.on("message", (data) => {
        const message = JSON.parse(data.toString());
        if (message.type === "welcome") {
          expect(message.clientId).toBeDefined();
          ws.close();
          resolve();
        }
      });

      ws.on("error", reject);
      setTimeout(() => reject(new Error("Timeout")), 5000);
    });
  });

  it("should echo client message", async () => {
    return new Promise<void>((resolve, reject) => {
      const ws = new WebSocket(WS_URL);
      let connected = false;

      ws.on("open", () => {
        connected = true;
        ws.send(
          JSON.stringify({
            type: "echo",
            payload: { message: "hello" },
          })
        );
      });

      ws.on("message", (data) => {
        const message = JSON.parse(data.toString());
        if (connected && message.type === "echo_response") {
          expect(message.payload.message).toBe("hello");
          ws.close();
          resolve();
        }
      });

      ws.on("error", reject);
      setTimeout(() => reject(new Error("Timeout")), 5000);
    });
  });

  it("should handle player join", async () => {
    return new Promise<void>((resolve, reject) => {
      const ws = new WebSocket(WS_URL);
      let connected = false;

      ws.on("open", () => {
        connected = true;
        ws.send(
          JSON.stringify({
            type: "join",
            payload: { playerId: "test_player_123" },
          })
        );
      });

      ws.on("message", (data) => {
        const message = JSON.parse(data.toString());
        if (connected && message.type === "join_ack") {
          expect(message.playerId).toBe("test_player_123");
          expect(message.status).toBe("joined");
          ws.close();
          resolve();
        }
      });

      ws.on("error", reject);
      setTimeout(() => reject(new Error("Timeout")), 5000);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Configuration Files

.gitignore (Git Ignore)

# Dependencies
node_modules/
package-lock.json
pnpm-lock.yaml

# Build
dist/
*.tsbuildinfo

# Environment
.env
.env.local
.env.*.local

# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store

# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Testing
coverage/
.nyc_output/

# Runtime
node_modules/.pnpm/
Enter fullscreen mode Exit fullscreen mode

eslint.config.js (ESLint Configuration)

import js from "@eslint/js";
import tseslint from "typescript-eslint";

export default [
  {
    ignores: ["dist/", "node_modules/", "**/*.config.js"],
  },
  js.configs.recommended,
  ...tseslint.configs.recommended,
  {
    files: ["src/**/*.ts"],
    languageOptions: {
      parser: tseslint.parser,
      parserOptions: {
        ecmaVersion: "latest",
        sourceType: "module",
      },
    },
    rules: {
      "@typescript-eslint/no-unused-vars": [
        "error",
        { argsIgnorePattern: "^_" },
      ],
      "@typescript-eslint/no-explicit-any": "warn",
      "@typescript-eslint/explicit-function-return-types": "warn",
      "no-console": ["warn", { allow: ["warn", "error"] }],
    },
  },
];
Enter fullscreen mode Exit fullscreen mode

jest.config.js (Jest Testing Configuration)

export default {
  preset: "ts-jest",
  testEnvironment: "node",
  roots: ["<rootDir>/tests"],
  testMatch: ["**/__tests__/**/*.ts", "**/?(*.)+(spec|test).ts"],
  collectCoverageFrom: ["src/**/*.ts", "!src/**/*.d.ts", "!src/**/index.ts"],
  coverageThreshold: {
    global: {
      branches: 50,
      functions: 50,
      lines: 50,
      statements: 50,
    },
  },
  moduleNameMapper: {
    "^(\\.{1,2}/.*)\\.js$": "$1",
  },
  transform: {
    "^.+\\.tsx?$": [
      "ts-jest",
      {
        useESM: true,
      },
    ],
  },
  extensionsToTreatAsEsm: [".ts"],
};
Enter fullscreen mode Exit fullscreen mode

Running the Project

Option 1: Docker Compose (Recommended)

# Start all services
docker-compose up --build

# In another terminal, verify services
curl http://localhost:3000
redis-cli -p 6379 ping  # Should output PONG
Enter fullscreen mode Exit fullscreen mode

Option 2: Local Setup

# Terminal 1: Start Redis
redis-server

# Terminal 2: Start development server
npm run dev

# Terminal 3: Run tests
npm run test
Enter fullscreen mode Exit fullscreen mode

Manual Testing

Create a simple test client script at scripts/test-ws.js:

import WebSocket from "ws";

const ws = new WebSocket("ws://localhost:8000");

ws.on("open", () => {
  console.log("✅ Connected to WebSocket server");

  // Test ping
  ws.send(JSON.stringify({ type: "ping" }));

  // Test echo
  setTimeout(() => {
    ws.send(
      JSON.stringify({
        type: "echo",
        payload: { message: "Hello World" },
      })
    );
  }, 500);

  // Test join
  setTimeout(() => {
    ws.send(
      JSON.stringify({
        type: "join",
        payload: { playerId: "test_player_123" },
      })
    );
  }, 1000);

  // Test progress
  setTimeout(() => {
    ws.send(
      JSON.stringify({
        type: "player_progress",
        payload: { playerId: "test_player_123", progress: 42 },
      })
    );
  }, 1500);

  // Close connection
  setTimeout(() => {
    ws.close();
  }, 2000);
});

ws.on("message", (data) => {
  const message = JSON.parse(data);
  console.log("📨 Received:", message);
});

ws.on("close", () => {
  console.log("❌ Connection closed");
});

ws.on("error", (error) => {
  console.error("❌ Error:", error);
});
Enter fullscreen mode Exit fullscreen mode

Run with:

node scripts/test-ws.js
Enter fullscreen mode Exit fullscreen mode

Acceptance Checklist

Use this checklist to verify Week 0 completion:

Setup

  • [x] Node.js 18+ installed and verified
  • [x] Docker & Docker Compose installed
  • [x] Git initialized with README
  • [x] .env file configured
  • [x] Dependencies installed (pnpm install or npm install)

Code

  • [x] WebSocket echo server running (src/ws/server.ts)
  • [x] Redis client initialized (src/redis/client.ts)
  • [x] Basic Redis operations implemented (src/redis/operations.ts)
  • [x] Message handlers working (src/ws/handlers.ts)
  • [x] Logging utility in place (src/utils/logger.ts)
  • [x] Input validators created (src/utils/validators.ts)

Testing

  • [x] Unit tests written and passing
  • [x] Integration tests written and passing
  • [x] Manual WebSocket tests verified
  • [x] Redis operations tested manually

DevOps

  • [x] Dockerfile with multi-stage build
  • [x] docker-compose.yml with Redis service
  • [x] GitHub Actions CI pipeline (.github/workflows/ci.yml)
  • [x] Docker build & push workflow (.github/workflows/docker.yml)
  • [x] ESLint configured and passing
  • [x] Jest configured

Deliverables

  • [x] Git repo with all code committed
  • [x] README.md with setup instructions
  • [x] WEEK_0_SETUP.md (this file)
  • [x] docker-compose.yml for local development
  • [x] Dockerfile for production
  • [x] CI/CD pipelines configured

Verification Commands

# 1. Verify code compiles
npm run build

# 2. Verify linting passes
npm run lint

# 3. Verify tests pass
npm run test

# 4. Start services
docker-compose up --build

# 5. In another terminal, verify Redis works
redis-cli ping

# 6. Verify WebSocket works (run the test script)
node scripts/test-ws.js
Enter fullscreen mode Exit fullscreen mode

All commands should complete without errors.


Key Learnings & Resources

JavaScript/TypeScript

WebSockets

Redis

Docker

CI/CD


Summary

Week 0 Complete! ✅

You now have:

  1. ✅ Working WebSocket echo server
  2. ✅ Redis client with basic operations
  3. ✅ Docker & Docker Compose setup
  4. ✅ CI/CD pipeline with GitHub Actions
  5. ✅ Unit & integration tests
  6. ✅ Linting and code quality tools
  7. ✅ Comprehensive documentation

Next week: Week 1 focuses on system design and domain modeling. Start designing your race state machine and key patterns.


Last Updated: November 7, 2025

Week: 0 / 12

Top comments (0)