brackt/plans/completed/phase-3-socket-implementation-plan.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

7.5 KiB

Phase 3: Socket.ts Implementation Plan - Avoiding Build Issues

The Problem

React Router v7 bundles server/app.ts into build/server/index.js. If we're not careful, Socket.IO code could:

  1. Get bundled into the server build (causing issues)
  2. Try to import from bundled code (import path problems)
  3. Create circular dependencies
  4. Break HMR in development

Solution Strategy

Structure:

/server.js           <- Entry point (not bundled)
/server/
  socket.js          <- Socket.IO code (not bundled, plain JS)
  app.ts             <- React Router app (gets bundled)

Why this works:

  • server.js is the entry point and NOT bundled by React Router
  • server/socket.js is imported by server.js, not by React Router code
  • No circular dependencies
  • Socket.IO stays completely separate from React Router's build

Option 2: Use Dynamic Import (Alternative)

Keep Socket.IO in TypeScript but use dynamic imports to prevent bundling:

// In server.js
const { initializeSocketIO } = await import("./server/socket.js");

This ensures Socket.IO is loaded at runtime, not bundled.

Detailed Implementation Plan

Step 1: Create server/socket.js (JavaScript, not TypeScript)

File: server/socket.js

// Use CommonJS or ES modules based on your Node version
import { Server as SocketIOServer } from "socket.io";

let io = null;

export function initializeSocketIO(httpServer) {
  if (io) {
    console.log("Socket.IO already initialized");
    return io;
  }

  // Create Socket.IO server
  io = new SocketIOServer(httpServer, {
    // CORS only needed if clients connect from different origin
    cors: process.env.NODE_ENV === "production" && process.env.APP_URL
      ? {
          origin: process.env.APP_URL,
          credentials: true,
        }
      : undefined, // No CORS in dev (same origin)
  });

  // Connection handling
  io.on("connection", (socket) => {
    console.log("Client connected:", socket.id);

    // Join draft room
    socket.on("join-draft", (seasonId) => {
      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) => {
      if (!seasonId) return;
      socket.leave(`draft-${seasonId}`);
      console.log(`Socket ${socket.id} left draft-${seasonId}`);
    });

    socket.on("disconnect", () => {
      console.log("Client disconnected:", socket.id);
    });
  });

  // Store globally for access in route handlers
  // This is safe because server.js controls the lifecycle
  global.__socketIO = io;

  console.log("Socket.IO initialized on", httpServer.address());
  return io;
}

export function getSocketIO() {
  // Check both local and global storage
  const instance = io || global.__socketIO;
  if (!instance) {
    throw new Error("Socket.IO not initialized. Call initializeSocketIO first.");
  }
  return instance;
}

Step 2: Create TypeScript definitions

File: server/socket.d.ts (Type definitions for socket.js)

import type { Server as HTTPServer } from "http";
import type { Server as SocketIOServer } from "socket.io";

export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer;
export function getSocketIO(): SocketIOServer;

File: server/types.d.ts (Global type augmentation)

import type { Server as SocketIOServer } from "socket.io";

declare global {
  var __socketIO: SocketIOServer | undefined;
}

export {};

Step 3: Update server.js

import compression from "compression";
import express from "express";
import morgan from "morgan";
import { createServer } from "http";

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");

// ... existing middleware setup ...

// Create HTTP server
const httpServer = createServer(app);

// Initialize Socket.IO AFTER creating HTTP server
// Dynamic import to ensure it's not bundled
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}`);
});

Step 4: Access Socket.IO from React Router Routes

Option A: Direct Import (May have path issues)

// In app/routes/api.draft-pick.ts
import { getSocketIO } from "../../server/socket.js";

export async function action({ request }: ActionFunctionArgs) {
  // Make database changes...
  
  // Broadcast update
  const io = getSocketIO();
  io.to(`draft-${seasonId}`).emit("pick-made", data);
  
  return json({ success: true });
}

Option B: Through Context (Cleaner but more setup)

// In server/app.ts
import { getSocketIO } from "./socket.js";

// Add to loader context
export function getLoadContext() {
  return {
    getSocketIO, // Pass function reference
  };
}

// In route
export async function action({ request, context }: ActionFunctionArgs) {
  const io = context.getSocketIO();
  io.to(`draft-${seasonId}`).emit("pick-made", data);
}

Build Process Verification

Development Mode Testing

npm run dev
# Check console for "Socket.IO initialized"
# Verify no build errors
# Test HMR still works

Production Build Testing

npm run build
# Check build/server/index.js
# Socket.IO code should NOT be in the bundle
# Only server/app.ts code should be bundled

# Run production
NODE_ENV=production node server.js
# Verify Socket.IO initializes

Potential Issues & Solutions

Issue 1: Import path resolution

Problem: Can't import server/socket.js from React Router routes Solution: Use global access pattern or pass through context

Issue 2: TypeScript errors

Problem: TS can't find types for server/socket.js Solution: Create server/socket.d.ts with type definitions

Issue 3: HMR breaks Socket.IO

Problem: Hot reloads create multiple Socket.IO instances Solution: The if (io) check prevents re-initialization

Issue 4: Build includes Socket.IO

Problem: Socket.IO gets bundled into build/server/index.js Solution: Keep as .js file, use dynamic import in server.js

Why .js Instead of .ts?

  1. No bundling: .js files aren't processed by React Router's build
  2. Simple imports: No build step needed for server.js to import it
  3. Clear separation: Socket.IO logic separate from React Router app
  4. Runtime loading: Loaded when server.js runs, not at build time

Alternative: Using TypeScript

If you really want TypeScript for Socket.IO:

  1. Create server/socket.ts
  2. Add a separate build step: tsc server/socket.ts --outDir dist
  3. Import from dist/socket.js in server.js
  4. Add to package.json scripts:
    "build:socket": "tsc server/socket.ts --outDir dist",
    "build": "npm run build:socket && react-router build"
    

But this adds complexity for minimal benefit.

Use server/socket.js (plain JavaScript)

  • Simpler
  • No build step
  • No bundling issues
  • Still get type safety through .d.ts files
  • Industry standard approach

This keeps Socket.IO completely separate from React Router's build process while maintaining type safety and avoiding all bundling issues.