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
- Prerequisites
- Environment Setup
- Project Structure
- Docker & Docker Compose
- WebSocket Echo Server
- Redis Client & Basic Operations
- CI/CD Pipeline (GitHub Actions)
- Testing
- Running the Project
- Acceptance Checklist
Prerequisites
Technologies to Install
-
Node.js (v18+, recommended v20 LTS)
- Download: https://nodejs.org/
- Verify:
node --version&&npm --version
-
TypeScript (via npm)
- Will be installed as dev dependency
-
Docker & Docker Compose
- Download: https://www.docker.com/products/docker-desktop
- Verify:
docker --version&&docker-compose --version
-
Git
- Download: https://git-scm.com/
- Verify:
git --version
-
pnpm (optional but recommended)
- Install:
npm install -g pnpm - Verify:
pnpm --version
- Install:
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"
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"
}
}
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
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
For Docker environments, create .env.docker:
NODE_ENV=development
PORT=3000
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_DB=0
LOG_LEVEL=debug
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
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"]
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
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();
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)}`;
}
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",
})
);
}
}
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;
}
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;
}
}
}
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 || "");
}
},
};
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,
};
}
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[];
}
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"
.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
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);
});
});
});
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);
});
});
});
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);
});
});
});
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/
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"] }],
},
},
];
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"],
};
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
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
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);
});
Run with:
node scripts/test-ws.js
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 installornpm 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
All commands should complete without errors.
Key Learnings & Resources
JavaScript/TypeScript
WebSockets
Redis
Docker
CI/CD
Summary
Week 0 Complete! ✅
You now have:
- ✅ Working WebSocket echo server
- ✅ Redis client with basic operations
- ✅ Docker & Docker Compose setup
- ✅ CI/CD pipeline with GitHub Actions
- ✅ Unit & integration tests
- ✅ Linting and code quality tools
- ✅ 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)