refactor: migrate server code from JavaScript to TypeScript with build pipeline

This commit is contained in:
Chris Parsons 2025-10-18 22:16:04 -07:00
parent 9d41508fd0
commit 2bad3dd75e
10 changed files with 1930 additions and 1089 deletions

3
.gitignore vendored
View file

@ -6,3 +6,6 @@
# React Router # React Router
/.react-router/ /.react-router/
/build/ /build/
# Compiled server output
/dist/

1543
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -3,13 +3,15 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "react-router build", "build": "npm run build:remix && npm run build:server",
"build:remix": "react-router build",
"build:server": "node scripts/build-server.mjs",
"db:generate": "dotenv -- drizzle-kit generate", "db:generate": "dotenv -- drizzle-kit generate",
"db:migrate": "dotenv -- drizzle-kit migrate", "db:migrate": "dotenv -- drizzle-kit migrate",
"dev": "dotenv -- node server.js", "dev": "dotenv -- tsx watch server.ts",
"start": "node server.js", "start": "node dist/server.js",
"start:production": "drizzle-kit migrate && node server.js", "start:production": "drizzle-kit migrate && node dist/server.js",
"typecheck": "react-router typegen && tsc -b" "typecheck": "react-router typegen && tsc -b && tsc -p tsconfig.server.json --noEmit"
}, },
"dependencies": { "dependencies": {
"@clerk/react-router": "^2.1.0", "@clerk/react-router": "^2.1.0",
@ -50,17 +52,18 @@
"devDependencies": { "devDependencies": {
"@react-router/dev": "^7.7.1", "@react-router/dev": "^7.7.1",
"@tailwindcss/vite": "^4.1.4", "@tailwindcss/vite": "^4.1.4",
"@types/compression": "^1.7.5", "@types/compression": "^1.8.1",
"@types/express": "^5.0.1", "@types/express": "^5.0.3",
"@types/express-serve-static-core": "^5.0.6", "@types/express-serve-static-core": "^5.0.6",
"@types/morgan": "^1.9.9", "@types/morgan": "^1.9.10",
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.11.14", "@types/pg": "^8.11.14",
"@types/react": "^19.1.2", "@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2", "@types/react-dom": "^19.1.2",
"dotenv-cli": "^8.0.0", "dotenv-cli": "^8.0.0",
"esbuild": "^0.25.11",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",
"tsx": "^4.19.2", "tsx": "^4.20.6",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.3.3", "vite": "^6.3.3",

View file

@ -0,0 +1,655 @@
# Server TypeScript Conversion Plan (With Compilation)
## Overview
Convert the Node.js server from JavaScript to TypeScript with a build step for production. This approach uses tsx for development (fast iteration) but compiles to optimized JavaScript for production (zero runtime overhead).
## Key Differences from TSX-Only Approach
- **Development**: Uses `tsx` for fast iteration and hot reload
- **Production**: Compiles to JavaScript, runs pure Node.js (no TypeScript overhead)
- **Build Process**: Adds a server compilation step
- **Docker**: Smaller production image (no tsx runtime needed)
## Current State Analysis
### Files to Convert
1. **`server.js`** (57 lines) - Main server entry point
2. **`server/socket.js`** (89 lines) - Socket.IO server
### Existing TypeScript Configuration
- **`tsconfig.node.json`** already includes `server/**/*.ts`
- Module resolution is set to "bundler" with ES2022 target
- Path aliases configured: `~/` for app, `~/database/` for database
## Implementation Steps
### Step 1: Install Required Dependencies
```bash
# Development dependencies
npm install --save-dev tsx @types/express @types/morgan @types/compression esbuild
# Why these packages?
# - tsx: Fast TypeScript execution for development
# - @types/*: TypeScript definitions
# - esbuild: Ultra-fast TypeScript compiler for production builds
```
### Step 2: Create Server TypeScript Configuration
**File: `tsconfig.server.json`**
```json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false,
"noEmit": false,
"outDir": "./dist/server",
"rootDir": ".",
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"lib": ["ES2022"],
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"],
"~/database/*": ["./database/*"]
}
},
"include": [
"server.ts",
"server/**/*.ts",
"database/**/*.ts",
"app/database/**/*.ts"
],
"exclude": [
"node_modules",
"build",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}
```
### Step 3: Create Build Script for Server
**File: `scripts/build-server.mjs`**
```javascript
import * as esbuild from 'esbuild';
import { nodeExternalsPlugin } from 'esbuild-node-externals';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function build() {
try {
await esbuild.build({
entryPoints: ['./server.ts'],
bundle: true,
platform: 'node',
target: 'node20',
format: 'esm',
outfile: 'dist/server.js',
sourcemap: true,
minify: process.env.NODE_ENV === 'production',
// Handle TypeScript path aliases
alias: {
'~': path.resolve(__dirname, '../app'),
'~/database': path.resolve(__dirname, '../database'),
},
// External dependencies (don't bundle node_modules)
external: [
'express',
'compression',
'morgan',
'vite',
'socket.io',
'drizzle-orm',
'postgres',
'@clerk/*',
'./build/server/index.js', // Production build reference
],
// Keep import.meta.url working
banner: {
js: `
import { createRequire } from 'module';
import { fileURLToPath } from 'url';
import path from 'path';
const require = createRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
`.trim()
}
});
console.log('✅ Server build complete');
} catch (error) {
console.error('❌ Build failed:', error);
process.exit(1);
}
}
build();
```
### Step 4: Convert server.js to server.ts
**File: `server.ts`**
```typescript
import compression from "compression";
import express, { Express, Request, Response, NextFunction } from "express";
import morgan from "morgan";
import { createServer } from "http";
import type { ViteDevServer } from "vite";
// Configuration
const BUILD_PATH = "./build/server/index.js";
const DEVELOPMENT = process.env.NODE_ENV === "development";
const PORT = Number.parseInt(process.env.PORT || "3000", 10);
async function createAppServer(): Promise<void> {
const app: Express = express();
app.use(compression());
app.disable("x-powered-by");
if (DEVELOPMENT) {
console.log("Starting development server");
// Dynamic import Vite only in development
const { createServer: createViteServer } = await import("vite");
const viteDevServer: ViteDevServer = await createViteServer({
server: { middlewareMode: true },
});
app.use(viteDevServer.middlewares);
app.use(async (req: Request, res: Response, next: NextFunction) => {
try {
const source = await viteDevServer.ssrLoadModule("./server/app.ts");
return await source.app(req, res, next);
} catch (error) {
if (error instanceof Error) {
viteDevServer.ssrFixStacktrace(error);
}
next(error);
}
});
} else {
console.log("Starting production server");
app.use(
"/assets",
express.static("build/client/assets", {
immutable: true,
maxAge: "1y"
})
);
app.use(morgan("tiny"));
app.use(express.static("build/client", { maxAge: "1h" }));
// Import the built React Router app
const { app: productionApp } = await import(BUILD_PATH);
app.use(productionApp);
}
// Create HTTP server
const httpServer = createServer(app);
// Initialize Socket.IO - will be compiled to .js in production
const { initializeSocketIO } = await import("./server/socket");
initializeSocketIO(httpServer);
// Start server
httpServer.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
}
// Start the server with error handling
createAppServer().catch((error) => {
console.error("Failed to start server:", error);
process.exit(1);
});
```
### Step 5: Convert server/socket.js to server/socket.ts
**File: `server/socket.ts`**
```typescript
import { Server as SocketIOServer, Socket } from "socket.io";
import type { Server as HTTPServer } from "http";
// Socket event types
interface ServerToClientEvents {
"test-message": (data: {
originalMessage: any;
serverResponse: string;
serverTimestamp: string;
socketId: string;
}) => void;
"pick-made": (data: any) => void;
"draft-started": (data: { seasonId: string; currentPickNumber: number }) => void;
"draft-completed": () => void;
"timer-update": (data: {
seasonId: string;
teamId: string;
timeRemaining: number;
currentPickNumber: number;
}) => void;
}
interface ClientToServerEvents {
"join-draft": (seasonId: string) => void;
"leave-draft": (seasonId: string) => void;
"test-event": (data: any) => void;
}
// Global type augmentation
declare global {
var __socketIO: SocketIOServer | undefined;
}
let io: SocketIOServer<ClientToServerEvents, ServerToClientEvents> | null = null;
/**
* Initialize Socket.IO server
*/
export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
if (io) {
console.log("Socket.IO already initialized");
return io;
}
// Create typed Socket.IO server
io = new SocketIOServer<ClientToServerEvents, ServerToClientEvents>(httpServer, {
cors: process.env.NODE_ENV === "production" && process.env.APP_URL
? {
origin: process.env.APP_URL,
credentials: true,
}
: undefined,
});
// Connection handling
io.on("connection", (socket: Socket<ClientToServerEvents, ServerToClientEvents>) => {
console.log("Client connected:", socket.id);
socket.on("join-draft", (seasonId: string) => {
if (!seasonId) {
console.error("No seasonId provided for join-draft");
return;
}
socket.join(`draft-${seasonId}`);
console.log(`Socket ${socket.id} joined draft-${seasonId}`);
});
socket.on("leave-draft", (seasonId: string) => {
if (!seasonId) return;
socket.leave(`draft-${seasonId}`);
console.log(`Socket ${socket.id} left draft-${seasonId}`);
});
socket.on("test-event", (data: any) => {
console.log("📨 Received test-event from client:", socket.id, data);
socket.emit("test-message", {
originalMessage: data,
serverResponse: "Hello from server!",
serverTimestamp: new Date().toISOString(),
socketId: socket.id,
});
console.log("✅ Sent test-message response to client:", socket.id);
});
socket.on("disconnect", () => {
console.log("Client disconnected:", socket.id);
});
});
// Store globally for route handlers
global.__socketIO = io;
console.log("Socket.IO initialized");
return io;
}
/**
* Get the Socket.IO server instance
*/
export function getSocketIO(): SocketIOServer {
const instance = io || global.__socketIO;
if (!instance) {
throw new Error("Socket.IO not initialized. Call initializeSocketIO first.");
}
return instance;
}
```
### Step 6: Update package.json Scripts
```json
{
"scripts": {
"build": "npm run build:remix && npm run build:server",
"build:remix": "react-router build",
"build:server": "node scripts/build-server.mjs",
"db:generate": "dotenv -- drizzle-kit generate",
"db:migrate": "dotenv -- drizzle-kit migrate",
"dev": "dotenv -- tsx watch server.ts",
"start": "node dist/server.js",
"start:production": "drizzle-kit migrate && node dist/server.js",
"typecheck": "react-router typegen && tsc -b && tsc -p tsconfig.server.json --noEmit"
}
}
```
**Key Changes:**
- `build`: Now builds both React Router app AND server
- `build:server`: Compiles TypeScript server to JavaScript
- `dev`: Uses `tsx watch` for development (fast, no compilation)
- `start`: Runs compiled JavaScript (no TypeScript overhead)
- `typecheck`: Also checks server TypeScript
### Step 7: Update Dockerfile (Optimized for Production)
```dockerfile
# Stage 1: Install all dependencies
FROM node:20-alpine AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci
# Stage 2: Build the application
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
# Build both React Router app and server
RUN npm run build
# Stage 3: Production dependencies only
FROM node:20-alpine AS prod-dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
# Stage 4: Final production image (minimal)
FROM node:20-alpine
WORKDIR /app
# Copy only what's needed for production
COPY package*.json ./
COPY --from=prod-dependencies /app/node_modules ./node_modules
COPY --from=builder /app/build ./build
COPY --from=builder /app/dist ./dist
COPY ./drizzle ./drizzle
COPY ./drizzle.config.ts ./drizzle.config.ts
# No TypeScript files needed in production!
# The server runs pure JavaScript from dist/server.js
EXPOSE 3000
CMD ["npm", "run", "start:production"]
```
### Step 8: Create Timer System (With Proper Imports)
**File: `server/timer.ts`**
```typescript
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, desc, asc, inArray, notInArray } from "drizzle-orm";
import { getSocketIO } from "./socket";
let timerInterval: NodeJS.Timeout | null = null;
export function startDraftTimerSystem(): void {
if (timerInterval) {
clearInterval(timerInterval);
}
timerInterval = setInterval(async () => {
try {
await updateDraftTimers();
} catch (error) {
console.error("[Timer] Error updating draft timers:", error);
}
}, 1000);
console.log("[Timer] Draft timer system started");
}
async function updateDraftTimers(): Promise<void> {
const db = database();
const io = getSocketIO();
// Get all active drafts
const activeDrafts = await db.query.seasons.findMany({
where: eq(schema.seasons.status, "draft"),
});
for (const season of activeDrafts) {
if (season.draftPaused) continue;
const currentPickNumber = season.currentPickNumber || 1;
// Get draft slots
const draftSlots = await db.query.draftSlots.findMany({
where: eq(schema.draftSlots.seasonId, season.id),
orderBy: schema.draftSlots.draftOrder,
});
// Calculate current team
const totalTeams = draftSlots.length;
const currentRound = Math.ceil(currentPickNumber / totalTeams);
const isEvenRound = currentRound % 2 === 0;
let pickInRound = ((currentPickNumber - 1) % totalTeams) + 1;
if (isEvenRound) {
pickInRound = totalTeams - pickInRound + 1;
}
const currentDraftSlot = draftSlots.find(
(slot) => slot.draftOrder === pickInRound
);
if (!currentDraftSlot) continue;
// Update timer
const timer = await db.query.draftTimers.findFirst({
where: and(
eq(schema.draftTimers.seasonId, season.id),
eq(schema.draftTimers.teamId, currentDraftSlot.teamId)
),
});
if (!timer) continue;
const newTimeRemaining = Math.max(0, timer.timeRemaining - 1);
// Update in database
await db
.update(schema.draftTimers)
.set({
timeRemaining: newTimeRemaining,
updatedAt: new Date(),
})
.where(eq(schema.draftTimers.id, timer.id));
// Broadcast update
io.to(`draft-${season.id}`).emit("timer-update", {
seasonId: season.id,
teamId: currentDraftSlot.teamId,
timeRemaining: newTimeRemaining,
currentPickNumber,
});
// Trigger auto-pick if timer expired
if (newTimeRemaining === 0 && timer.timeRemaining > 0) {
console.log(`[Timer] Timer expired for team ${currentDraftSlot.teamId}`);
// Auto-pick logic here...
}
}
}
```
### Step 9: Update .gitignore
Add these entries:
```
# Compiled server output
/dist
dist/
# Keep source TypeScript files
!server.ts
!server/**/*.ts
```
## Alternative: Use Vite to Build Server
If you prefer to use Vite for consistency, create:
**File: `vite.config.server.ts`**
```typescript
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
build: {
ssr: true,
target: 'node20',
outDir: 'dist',
rollupOptions: {
input: './server.ts',
external: [
/^node:/,
'express',
'compression',
'morgan',
'vite',
'socket.io',
'drizzle-orm',
'postgres',
],
output: {
format: 'es',
},
},
},
plugins: [tsconfigPaths()],
ssr: {
noExternal: ['~/database', '~/app'],
},
});
```
Then update build script:
```json
"build:server": "vite build --config vite.config.server.ts"
```
## Testing Plan
### 1. Development Testing
```bash
# Start with tsx (fast, no compilation)
npm run dev
# Verify:
- Server starts quickly
- Hot reload works
- Can import TypeScript modules
- Socket.IO connects
```
### 2. Build Testing
```bash
# Build server
npm run build:server
# Check output
ls -la dist/
# Should see: server.js, server.js.map
# Test compiled server
NODE_ENV=production npm start
# Verify:
- Runs pure JavaScript (no tsx)
- All features work
- No TypeScript overhead
```
### 3. Performance Comparison
```bash
# Measure startup time with tsx
time npm run dev
# Measure startup time with compiled JS
time npm start
# Compiled version should be faster
```
## Benefits of This Approach
1. **Development Speed**: tsx provides fast iteration in development
2. **Production Performance**: Zero TypeScript overhead in production
3. **Type Safety**: Full TypeScript benefits during development
4. **Smaller Docker Image**: No tsx or TypeScript in production container
5. **Faster Startup**: Compiled JavaScript starts faster than tsx
6. **Debugging**: Source maps available in both dev and production
## Migration Checklist
- [ ] Install dependencies (tsx, esbuild, types)
- [ ] Create tsconfig.server.json
- [ ] Create build-server.mjs script
- [ ] Convert server.js to server.ts
- [ ] Convert server/socket.js to server/socket.ts
- [ ] Update package.json scripts
- [ ] Test development mode with tsx
- [ ] Test build process
- [ ] Test production with compiled JS
- [ ] Update Dockerfile
- [ ] Update .gitignore
- [ ] Implement timer system
- [ ] Remove old .js files
## Performance Metrics
Expected improvements:
- **Startup time**: 30-50% faster (no TypeScript compilation)
- **Memory usage**: 20-30% less (no tsx runtime)
- **CPU usage**: Lower (no runtime transpilation)
- **Docker image size**: ~50MB smaller (no TypeScript dependencies)
## Rollback Plan
1. Keep original .js files until testing complete
2. Git commit before conversion
3. Can revert package.json to use .js files
4. Compiled output is separate from source

View file

@ -0,0 +1,493 @@
# Server TypeScript Conversion Plan
## Overview
Convert the Node.js server from JavaScript to TypeScript to improve type safety, developer experience, and enable proper imports of TypeScript modules (database, schemas, etc.) from within the server code.
## Current State Analysis
### Files to Convert
1. **`server.js`** (57 lines) - Main server entry point
- Handles Vite dev server setup
- Production build serving
- Socket.IO initialization
- Express middleware
2. **`server/socket.js`** (89 lines) - Socket.IO server
- Connection handling
- Draft room management
- Global instance storage
### Existing TypeScript Configuration
- **`tsconfig.node.json`** already includes `server/**/*.ts`
- Module resolution is set to "bundler" with ES2022 target
- Path aliases configured: `~/` for app, `~/database/` for database
### Dependencies Already Installed
- TypeScript is already in devDependencies
- `@types/node` is configured in tsconfig.node.json
- Vite handles TypeScript compilation in development
## Implementation Steps
### Step 1: Install Required Dependencies
```bash
npm install --save-dev tsx @types/express @types/morgan @types/compression
```
**Why tsx?**
- Fastest TypeScript runner for Node.js
- No compilation step needed
- Works in both development and production
- Supports ESM modules and path aliases
- Better than ts-node for performance
### Step 2: Convert server.js to server.ts
**File: `server.ts`**
```typescript
import compression from "compression";
import express, { Express } from "express";
import morgan from "morgan";
import { createServer } from "http";
import { ViteDevServer } from "vite";
import path from "path";
import { fileURLToPath } from "url";
// ESM module resolution helpers
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Configuration
const BUILD_PATH = "./build/server/index.js";
const DEVELOPMENT = process.env.NODE_ENV === "development";
const PORT = Number.parseInt(process.env.PORT || "3000", 10);
async function createAppServer(): Promise<void> {
const app: Express = express();
app.use(compression());
app.disable("x-powered-by");
if (DEVELOPMENT) {
console.log("Starting development server");
// Dynamic import to avoid bundling Vite in production
const { createServer: createViteServer } = await import("vite");
const viteDevServer: ViteDevServer = await createViteServer({
server: { middlewareMode: true },
});
app.use(viteDevServer.middlewares);
app.use(async (req, res, next) => {
try {
const source = await viteDevServer.ssrLoadModule("./server/app.ts");
return await source.app(req, res, next);
} catch (error) {
if (error instanceof Error) {
viteDevServer.ssrFixStacktrace(error);
}
next(error);
}
});
} else {
console.log("Starting production server");
app.use(
"/assets",
express.static("build/client/assets", {
immutable: true,
maxAge: "1y"
})
);
app.use(morgan("tiny"));
app.use(express.static("build/client", { maxAge: "1h" }));
// Dynamic import for production build
const { app: productionApp } = await import(BUILD_PATH);
app.use(productionApp);
}
// Create HTTP server
const httpServer = createServer(app);
// Initialize Socket.IO
const { initializeSocketIO } = await import("./server/socket.js");
initializeSocketIO(httpServer);
// Start server
httpServer.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
}
// Start the server
createAppServer().catch((error) => {
console.error("Failed to start server:", error);
process.exit(1);
});
```
### Step 3: Convert server/socket.js to server/socket.ts
**File: `server/socket.ts`**
```typescript
import { Server as SocketIOServer, Socket } from "socket.io";
import { Server as HTTPServer } from "http";
// Global type augmentation for Socket.IO instance
declare global {
var __socketIO: SocketIOServer | undefined;
}
let io: SocketIOServer | null = null;
/**
* Initialize Socket.IO server
*/
export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
if (io) {
console.log("Socket.IO already initialized");
return io;
}
// Create Socket.IO server with proper typing
io = new SocketIOServer(httpServer, {
cors: process.env.NODE_ENV === "production" && process.env.APP_URL
? {
origin: process.env.APP_URL,
credentials: true,
}
: undefined,
});
// Connection handling with typed socket
io.on("connection", (socket: Socket) => {
console.log("Client connected:", socket.id);
// Join draft room
socket.on("join-draft", (seasonId: string) => {
if (!seasonId) {
console.error("No seasonId provided for join-draft");
return;
}
socket.join(`draft-${seasonId}`);
console.log(`Socket ${socket.id} joined draft-${seasonId}`);
});
// Leave draft room
socket.on("leave-draft", (seasonId: string) => {
if (!seasonId) return;
socket.leave(`draft-${seasonId}`);
console.log(`Socket ${socket.id} left draft-${seasonId}`);
});
// Test event handler
socket.on("test-event", (data: any) => {
console.log("📨 Received test-event from client:", socket.id, data);
socket.emit("test-message", {
originalMessage: data,
serverResponse: "Hello from server!",
serverTimestamp: new Date().toISOString(),
socketId: socket.id,
});
console.log("✅ Sent test-message response to client:", socket.id);
});
socket.on("disconnect", () => {
console.log("Client disconnected:", socket.id);
});
});
// Store globally for access in route handlers
global.__socketIO = io;
console.log("Socket.IO initialized");
return io;
}
/**
* Get the Socket.IO server instance
*/
export function getSocketIO(): SocketIOServer {
const instance = io || global.__socketIO;
if (!instance) {
throw new Error("Socket.IO not initialized. Call initializeSocketIO first.");
}
return instance;
}
```
### Step 4: Create Type Definitions for Socket Events (Optional but Recommended)
**File: `server/socket.types.ts`**
```typescript
// Define your socket event types for better type safety
export interface ServerToClientEvents {
"test-message": (data: {
originalMessage: any;
serverResponse: string;
serverTimestamp: string;
socketId: string;
}) => void;
"pick-made": (data: any) => void;
"draft-started": (data: { seasonId: string; currentPickNumber: number }) => void;
"draft-completed": () => void;
"timer-update": (data: {
seasonId: string;
teamId: string;
timeRemaining: number;
currentPickNumber: number;
}) => void;
}
export interface ClientToServerEvents {
"join-draft": (seasonId: string) => void;
"leave-draft": (seasonId: string) => void;
"test-event": (data: any) => void;
}
export interface InterServerEvents {
ping: () => void;
}
export interface SocketData {
userId?: string;
teamId?: string;
}
```
Then update `server/socket.ts` to use these types:
```typescript
import { Server as SocketIOServer } from "socket.io";
import type {
ServerToClientEvents,
ClientToServerEvents,
InterServerEvents,
SocketData
} from "./socket.types";
// Create typed Socket.IO server
io = new SocketIOServer<
ClientToServerEvents,
ServerToClientEvents,
InterServerEvents,
SocketData
>(httpServer, { /* ... */ });
```
### Step 5: Update package.json Scripts
```json
{
"scripts": {
"build": "react-router build",
"db:generate": "dotenv -- drizzle-kit generate",
"db:migrate": "dotenv -- drizzle-kit migrate",
"dev": "dotenv -- tsx watch server.ts",
"start": "tsx server.ts",
"start:production": "drizzle-kit migrate && tsx server.ts",
"typecheck": "react-router typegen && tsc -b"
}
}
```
**Key Changes:**
- `dev`: Uses `tsx watch` for auto-restart on file changes
- `start`: Uses `tsx` to run TypeScript directly
- `start:production`: Same as start but with migration
### Step 6: Update Dockerfile
```dockerfile
FROM node:20-alpine AS development-dependencies-env
COPY . /app
WORKDIR /app
RUN npm ci
FROM node:20-alpine AS production-dependencies-env
COPY ./package.json package-lock.json /app/
WORKDIR /app
RUN npm ci --omit=dev
FROM node:20-alpine AS build-env
COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN npm run build
FROM node:20-alpine
# Copy TypeScript files instead of JavaScript
COPY ./package.json package-lock.json server.ts /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
COPY ./drizzle /app/drizzle
COPY ./drizzle.config.ts /app/drizzle.config.ts
COPY ./server /app/server
COPY ./database /app/database
COPY ./app /app/app
# Copy TypeScript configs for tsx runtime
COPY ./tsconfig*.json /app/
WORKDIR /app
# Install tsx in production for running TypeScript
RUN npm install tsx
CMD ["npm", "run", "start:production"]
```
### Step 7: Update server.ts Import Path
After converting socket.js to socket.ts, update the import in server.ts:
```typescript
// Change from:
const { initializeSocketIO } = await import("./server/socket.js");
// To:
const { initializeSocketIO } = await import("./server/socket");
// Or explicitly:
const { initializeSocketIO } = await import("./server/socket.ts");
```
### Step 8: Add Timer Functionality (Now Possible!)
With TypeScript server, you can now properly import database modules:
**File: `server/timer.ts`**
```typescript
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and } from "drizzle-orm";
import { getSocketIO } from "./socket";
export async function startDraftTimerSystem(): Promise<void> {
setInterval(async () => {
try {
await updateDraftTimers();
} catch (error) {
console.error("[Timer] Error updating draft timers:", error);
}
}, 1000);
console.log("[Timer] Draft timer system started");
}
async function updateDraftTimers(): Promise<void> {
const db = database();
const io = getSocketIO();
// Get all active drafts
const activeDrafts = await db.query.seasons.findMany({
where: eq(schema.seasons.status, "draft"),
});
// ... rest of timer logic
}
```
Then in `server/socket.ts`, add:
```typescript
import { startDraftTimerSystem } from "./timer";
export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
// ... existing code ...
// Start timer system after Socket.IO is initialized
startDraftTimerSystem();
return io;
}
```
## Testing Plan
### 1. Development Mode Testing
```bash
# Start dev server with TypeScript
npm run dev
# Verify:
- Server starts without errors
- Vite dev server works
- Socket.IO connections work
- Hot reload works with tsx watch
- Can import database modules
```
### 2. Production Build Testing
```bash
# Build the app
npm run build
# Start production server
NODE_ENV=production npm start
# Verify:
- Server starts without errors
- Static assets are served
- Socket.IO works in production
- No TypeScript compilation errors
```
### 3. Docker Testing
```bash
# Build Docker image
docker build -t brackt-test .
# Run container
docker run -p 3000:3000 brackt-test
# Verify:
- Container starts successfully
- Application works in containerized environment
```
## Benefits of This Approach
1. **Type Safety**: Full TypeScript throughout the server
2. **Better Imports**: Can import TypeScript modules directly
3. **No Build Step**: tsx runs TypeScript directly
4. **Fast Development**: tsx watch provides fast restarts
5. **Production Ready**: tsx works in production without compilation
6. **Maintainable**: Cleaner code with proper types
7. **Debugging**: Better stack traces and error messages
## Migration Checklist
- [ ] Install tsx and type packages
- [ ] Convert server.js to server.ts
- [ ] Convert server/socket.js to server/socket.ts
- [ ] Create socket.types.ts for event types
- [ ] Update package.json scripts
- [ ] Update Dockerfile
- [ ] Test development mode
- [ ] Test production build
- [ ] Test Docker build
- [ ] Implement timer system with database imports
- [ ] Remove old .js files
## Rollback Plan
If issues arise:
1. Keep original .js files until testing complete
2. Can revert package.json scripts to use .js files
3. Git commit before starting conversion
4. Test thoroughly in development before deploying
## Notes for Implementation
- tsx handles TypeScript path aliases (`~/`) automatically
- No need for separate compilation step
- Works with existing Vite setup
- Compatible with React Router v7 SSR
- Maintains existing Socket.IO functionality
- Enables proper database module imports

57
scripts/build-server.mjs Normal file
View file

@ -0,0 +1,57 @@
import * as esbuild from 'esbuild';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function build() {
try {
await esbuild.build({
entryPoints: ['./server.ts'],
bundle: true,
platform: 'node',
target: 'node20',
format: 'esm',
outfile: 'dist/server.js',
sourcemap: true,
minify: process.env.NODE_ENV === 'production',
// Handle TypeScript path aliases by bundling app code
alias: {
'~': path.resolve(__dirname, '../app'),
'~/database': path.resolve(__dirname, '../database'),
},
// External dependencies (don't bundle node_modules)
// But DO bundle our application code (database, app modules)
external: [
'express',
'compression',
'morgan',
'vite',
'socket.io',
'drizzle-orm',
'postgres',
'@clerk/*',
'@react-router/*',
'../build/server/index.js', // Production build reference (relative to dist/)
],
// Keep import.meta.url working
// Note: path, fileURLToPath are imported in source, so we don't duplicate them here
banner: {
js: `
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
`.trim()
}
});
console.log('✅ Server build complete');
} catch (error) {
console.error('❌ Build failed:', error);
process.exit(1);
}
}
build();

View file

@ -1,56 +0,0 @@
import compression from "compression";
import express from "express";
import morgan from "morgan";
import { createServer } from "http";
// Short-circuit the type-checking of the built output.
const BUILD_PATH = "./build/server/index.js";
const DEVELOPMENT = process.env.NODE_ENV === "development";
const PORT = Number.parseInt(process.env.PORT || "3000");
const app = express();
app.use(compression());
app.disable("x-powered-by");
if (DEVELOPMENT) {
console.log("Starting development server");
const viteDevServer = await import("vite").then((vite) =>
vite.createServer({
server: { middlewareMode: true },
}),
);
app.use(viteDevServer.middlewares);
app.use(async (req, res, next) => {
try {
const source = await viteDevServer.ssrLoadModule("./server/app.ts");
return await source.app(req, res, next);
} catch (error) {
if (typeof error === "object" && error instanceof Error) {
viteDevServer.ssrFixStacktrace(error);
}
next(error);
}
});
} else {
console.log("Starting production server");
app.use(
"/assets",
express.static("build/client/assets", { immutable: true, maxAge: "1y" }),
);
app.use(morgan("tiny"));
app.use(express.static("build/client", { maxAge: "1h" }));
app.use(await import(BUILD_PATH).then((mod) => mod.app));
}
// Create HTTP server
const httpServer = createServer(app);
// Initialize Socket.IO
const { initializeSocketIO } = await import("./server/socket.js");
initializeSocketIO(httpServer);
// Use httpServer.listen instead of app.listen
httpServer.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});

87
server.ts Normal file
View file

@ -0,0 +1,87 @@
import compression from "compression";
import express, { Express, Request, Response, NextFunction } from "express";
import morgan from "morgan";
import { createServer } from "http";
import type { ViteDevServer } from "vite";
import path from "path";
import { fileURLToPath } from "url";
// ESM module resolution helpers
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Configuration
// Resolve paths relative to project root (one level up from dist/ in production, or current dir in dev)
const PROJECT_ROOT = path.resolve(__dirname, "..");
const BUILD_PATH = path.join(PROJECT_ROOT, "build/server/index.js");
const CLIENT_BUILD_PATH = path.join(PROJECT_ROOT, "build/client");
const CLIENT_ASSETS_PATH = path.join(PROJECT_ROOT, "build/client/assets");
const DEVELOPMENT = process.env.NODE_ENV === "development";
const PORT = Number.parseInt(process.env.PORT || "3000", 10);
async function createAppServer(): Promise<void> {
const app: Express = express();
app.use(compression());
app.disable("x-powered-by");
if (DEVELOPMENT) {
console.log("Starting development server");
// Dynamic import Vite only in development
const { createServer: createViteServer } = await import("vite");
const viteDevServer: ViteDevServer = await createViteServer({
server: { middlewareMode: true },
});
app.use(viteDevServer.middlewares);
app.use(async (req: Request, res: Response, next: NextFunction) => {
try {
const source = await viteDevServer.ssrLoadModule("./server/app.ts");
return await source.app(req, res, next);
} catch (error) {
if (error instanceof Error) {
viteDevServer.ssrFixStacktrace(error);
}
next(error);
}
});
} else {
console.log("Starting production server");
app.use(
"/assets",
express.static(CLIENT_ASSETS_PATH, {
immutable: true,
maxAge: "1y"
})
);
app.use(morgan("tiny"));
app.use(express.static(CLIENT_BUILD_PATH, { maxAge: "1h" }));
// Import the built React Router app
const { app: productionApp } = await import(BUILD_PATH);
app.use(productionApp);
}
// Create HTTP server
const httpServer = createServer(app);
// Initialize Socket.IO
const { initializeSocketIO } = await import("./server/socket");
initializeSocketIO(httpServer);
// Start server
httpServer.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
}
// Start the server with error handling
createAppServer().catch((error) => {
console.error("Failed to start server:", error);
process.exit(1);
});

View file

@ -1,36 +1,62 @@
import { Server as SocketIOServer } from "socket.io"; import { Server as SocketIOServer, Socket } from "socket.io";
import type { Server as HTTPServer } from "http";
/** @type {SocketIOServer | null} */ // Socket event types
let io = null; interface ServerToClientEvents {
"test-message": (data: {
originalMessage: any;
serverResponse: string;
serverTimestamp: string;
socketId: string;
}) => void;
"pick-made": (data: any) => void;
"draft-started": (data: { seasonId: string; currentPickNumber: number }) => void;
"draft-completed": () => void;
"timer-update": (data: {
seasonId: string;
teamId: string;
timeRemaining: number;
currentPickNumber: number;
}) => void;
}
interface ClientToServerEvents {
"join-draft": (seasonId: string) => void;
"leave-draft": (seasonId: string) => void;
"test-event": (data: any) => void;
}
// Global type augmentation
declare global {
var __socketIO: SocketIOServer | undefined;
}
let io: SocketIOServer<ClientToServerEvents, ServerToClientEvents> | null = null;
/** /**
* Initialize Socket.IO server * Initialize Socket.IO server
* @param {import('http').Server} httpServer
* @returns {SocketIOServer}
*/ */
export function initializeSocketIO(httpServer) { export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
if (io) { if (io) {
console.log("Socket.IO already initialized"); console.log("Socket.IO already initialized");
return io; return io;
} }
// Create Socket.IO server // Create typed Socket.IO server
io = new SocketIOServer(httpServer, { io = new SocketIOServer<ClientToServerEvents, ServerToClientEvents>(httpServer, {
// CORS only needed if clients connect from different origin
cors: process.env.NODE_ENV === "production" && process.env.APP_URL cors: process.env.NODE_ENV === "production" && process.env.APP_URL
? { ? {
origin: process.env.APP_URL, origin: process.env.APP_URL,
credentials: true, credentials: true,
} }
: undefined, // No CORS in dev (same origin) : undefined,
}); });
// Connection handling // Connection handling
io.on("connection", (socket) => { io.on("connection", (socket: Socket<ClientToServerEvents, ServerToClientEvents>) => {
console.log("Client connected:", socket.id); console.log("Client connected:", socket.id);
// Join draft room socket.on("join-draft", (seasonId: string) => {
socket.on("join-draft", (seasonId) => {
if (!seasonId) { if (!seasonId) {
console.error("No seasonId provided for join-draft"); console.error("No seasonId provided for join-draft");
return; return;
@ -39,18 +65,15 @@ export function initializeSocketIO(httpServer) {
console.log(`Socket ${socket.id} joined draft-${seasonId}`); console.log(`Socket ${socket.id} joined draft-${seasonId}`);
}); });
// Leave draft room socket.on("leave-draft", (seasonId: string) => {
socket.on("leave-draft", (seasonId) => {
if (!seasonId) return; if (!seasonId) return;
socket.leave(`draft-${seasonId}`); socket.leave(`draft-${seasonId}`);
console.log(`Socket ${socket.id} left draft-${seasonId}`); console.log(`Socket ${socket.id} left draft-${seasonId}`);
}); });
// Test event handler - echo back with server timestamp socket.on("test-event", (data: any) => {
socket.on("test-event", (data) => {
console.log("📨 Received test-event from client:", socket.id, data); console.log("📨 Received test-event from client:", socket.id, data);
// Echo back to the client with server response
socket.emit("test-message", { socket.emit("test-message", {
originalMessage: data, originalMessage: data,
serverResponse: "Hello from server!", serverResponse: "Hello from server!",
@ -66,8 +89,7 @@ export function initializeSocketIO(httpServer) {
}); });
}); });
// Store globally for access in route handlers // Store globally for route handlers
// This is safe because server.js controls the lifecycle
global.__socketIO = io; global.__socketIO = io;
console.log("Socket.IO initialized"); console.log("Socket.IO initialized");
@ -76,10 +98,8 @@ export function initializeSocketIO(httpServer) {
/** /**
* Get the Socket.IO server instance * Get the Socket.IO server instance
* @returns {SocketIOServer}
*/ */
export function getSocketIO() { export function getSocketIO(): SocketIOServer {
// Check both local and global storage
const instance = io || global.__socketIO; const instance = io || global.__socketIO;
if (!instance) { if (!instance) {
throw new Error("Socket.IO not initialized. Call initializeSocketIO first."); throw new Error("Socket.IO not initialized. Call initializeSocketIO first.");

38
tsconfig.server.json Normal file
View file

@ -0,0 +1,38 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false,
"noEmit": false,
"outDir": "./dist/server",
"rootDir": ".",
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"lib": ["ES2022"],
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"],
"~/database/*": ["./database/*"]
}
},
"include": [
"server.ts",
"server/**/*.ts",
"database/**/*.ts",
"app/database/**/*.ts"
],
"exclude": [
"node_modules",
"build",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}