brackt/plans/completed/server-typescript-conversion-compiled.md
Chris Parsons 35fe84a1dd
Update claude.md to push more tests. (#84)
* 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>
2026-03-07 22:31:04 -08:00

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 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

# 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 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)

# 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

  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