* Update claude.md to push more tests. * Archiving old plans. * fix: run DB migrations programmatically on server startup Replace unreliable drizzle-kit CLI migration with drizzle-orm's built-in migrator running in the server process before accepting connections. This ensures migrations are always applied on deploy and fails fast if they error. - Add runMigrations() to server.ts using drizzle-orm/postgres-js/migrator - Skip migrations in development (handled manually via db:migrate) - Add DATABASE_URL guard with a clear error message - Remove start:production script (now identical to start) - Update Dockerfile CMD to use npm run start Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
17 KiB
17 KiB
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
tsxfor 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
server.js(57 lines) - Main server entry pointserver/socket.js(89 lines) - Socket.IO server
Existing TypeScript Configuration
tsconfig.node.jsonalready includesserver/**/*.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
# 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
{
"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
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
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
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
{
"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 serverbuild:server: Compiles TypeScript server to JavaScriptdev: Usestsx watchfor development (fast, no compilation)start: Runs compiled JavaScript (no TypeScript overhead)typecheck: Also checks server TypeScript
Step 7: Update Dockerfile (Optimized for Production)
# 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
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
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:
"build:server": "vite build --config vite.config.server.ts"
Testing Plan
1. Development Testing
# 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
# 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
# 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
- Development Speed: tsx provides fast iteration in development
- Production Performance: Zero TypeScript overhead in production
- Type Safety: Full TypeScript benefits during development
- Smaller Docker Image: No tsx or TypeScript in production container
- Faster Startup: Compiled JavaScript starts faster than tsx
- 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
- Keep original .js files until testing complete
- Git commit before conversion
- Can revert package.json to use .js files
- Compiled output is separate from source