feat: plan Socket.IO server setup and client hook for real-time draft updates
This commit is contained in:
parent
b9743aacc1
commit
2f36d931fd
4 changed files with 834 additions and 16 deletions
|
|
@ -90,27 +90,165 @@ Build a real-time draft room using Socket.IO for live updates, with snake draft
|
||||||
|
|
||||||
## Phase 3: Socket.IO Setup
|
## Phase 3: Socket.IO Setup
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
Socket.IO requires access to the HTTP server instance (not just the Express app). The key pattern is:
|
||||||
|
```javascript
|
||||||
|
const server = http.createServer(app);
|
||||||
|
const io = socketIo(server);
|
||||||
|
server.listen(port); // NOT app.listen()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Architecture Decision
|
||||||
|
|
||||||
|
**Option A: Modify server.js (Recommended)**
|
||||||
|
- Change `app.listen()` to `http.createServer(app)` + `server.listen()`
|
||||||
|
- Initialize Socket.IO with the HTTP server
|
||||||
|
- Keep everything in one process
|
||||||
|
- ✅ Simple, standard pattern
|
||||||
|
- ✅ Socket.IO and Express share same port
|
||||||
|
- ✅ Works with React Router v7 SSR
|
||||||
|
- ❌ Requires modifying server.js
|
||||||
|
|
||||||
|
**Option B: Separate Socket Server**
|
||||||
|
- Run Socket.IO on different port (e.g., 3001)
|
||||||
|
- Keep server.js untouched
|
||||||
|
- ✅ No changes to existing server
|
||||||
|
- ❌ CORS complexity
|
||||||
|
- ❌ Two processes to manage
|
||||||
|
- ❌ Different ports for HTTP and WebSocket
|
||||||
|
|
||||||
|
**Decision: Use Option A** - Industry standard, simpler deployment, better DX
|
||||||
|
|
||||||
### 3.1 Server Setup
|
### 3.1 Server Setup
|
||||||
|
|
||||||
- Install `socket.io`
|
#### Step 1: Modify server.js to use HTTP server
|
||||||
- Create `server/socket.ts`
|
|
||||||
- Initialize Socket.IO server alongside Remix
|
**File: `server.js`**
|
||||||
- Set up draft room namespaces: `draft-${seasonId}`
|
|
||||||
|
Change from:
|
||||||
|
```javascript
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server is running on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
To:
|
||||||
|
```javascript
|
||||||
|
import { createServer } from "http";
|
||||||
|
|
||||||
|
const httpServer = createServer(app);
|
||||||
|
|
||||||
|
// Import and initialize Socket.IO
|
||||||
|
const { initializeSocketIO } = await import("./server/socket.js");
|
||||||
|
initializeSocketIO(httpServer);
|
||||||
|
|
||||||
|
httpServer.listen(PORT, () => {
|
||||||
|
console.log(`Server is running on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 2: Create Socket.IO module
|
||||||
|
|
||||||
|
**File: `server/socket.js`** (use .js not .ts to avoid bundling issues)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { Server as SocketIOServer } from "socket.io";
|
||||||
|
|
||||||
|
let io;
|
||||||
|
|
||||||
|
export function initializeSocketIO(httpServer) {
|
||||||
|
if (io) {
|
||||||
|
console.log("Socket.IO already initialized");
|
||||||
|
return io;
|
||||||
|
}
|
||||||
|
|
||||||
|
io = new SocketIOServer(httpServer, {
|
||||||
|
cors: {
|
||||||
|
origin: process.env.NODE_ENV === "production"
|
||||||
|
? process.env.APP_URL
|
||||||
|
: `http://localhost:${process.env.PORT || 3000}`,
|
||||||
|
credentials: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
io.on("connection", (socket) => {
|
||||||
|
console.log("Client connected:", socket.id);
|
||||||
|
|
||||||
|
// Join draft room
|
||||||
|
socket.on("join-draft", (seasonId) => {
|
||||||
|
socket.join(`draft-${seasonId}`);
|
||||||
|
console.log(`Socket ${socket.id} joined draft-${seasonId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Leave draft room
|
||||||
|
socket.on("leave-draft", (seasonId) => {
|
||||||
|
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
|
||||||
|
global.__socketIO = io;
|
||||||
|
|
||||||
|
console.log("Socket.IO initialized");
|
||||||
|
return io;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSocketIO() {
|
||||||
|
if (!io && !global.__socketIO) {
|
||||||
|
throw new Error("Socket.IO not initialized");
|
||||||
|
}
|
||||||
|
return io || global.__socketIO;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 3: Add global type definition
|
||||||
|
|
||||||
|
**File: `server/types.d.ts`**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Server as SocketIOServer } from "socket.io";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var __socketIO: SocketIOServer | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 4: Update tsconfig.node.json
|
||||||
|
|
||||||
|
Add server directory to includes:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"include": [
|
||||||
|
"server.js",
|
||||||
|
"server/**/*.js",
|
||||||
|
"server/**/*.ts",
|
||||||
|
"vite.config.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### 3.2 Socket Events (Server)
|
### 3.2 Socket Events (Server)
|
||||||
|
|
||||||
**Listen for:**
|
**Listen for:**
|
||||||
|
|
||||||
- `join-draft` - user joins draft room
|
- `join-draft` - user joins draft room
|
||||||
- `make-pick` - user makes a pick
|
- `leave-draft` - user leaves draft room
|
||||||
- `add-to-queue` - add player to queue
|
- `make-pick` - user makes a pick (handled via HTTP POST, not socket)
|
||||||
- `remove-from-queue` - remove from queue
|
- `add-to-queue` - add player to queue (handled via HTTP POST)
|
||||||
- `reorder-queue` - reorder queue
|
- `remove-from-queue` - remove from queue (handled via HTTP POST)
|
||||||
- `commissioner-force-pick` - commissioner forces pick
|
- `reorder-queue` - reorder queue (handled via HTTP POST)
|
||||||
- `commissioner-start-draft` - start draft
|
|
||||||
- `commissioner-pause-draft` - pause/resume draft
|
|
||||||
|
|
||||||
**Emit:**
|
**Note:** Write operations (picks, queue changes) should use HTTP POST requests for reliability and error handling. Socket.IO is for broadcasting updates to all clients.
|
||||||
|
|
||||||
|
**Emit (from server to clients):**
|
||||||
|
|
||||||
- `pick-made` - broadcast new pick to room
|
- `pick-made` - broadcast new pick to room
|
||||||
- `timer-update` - broadcast timer updates (every second)
|
- `timer-update` - broadcast timer updates (every second)
|
||||||
|
|
@ -122,10 +260,171 @@ Build a real-time draft room using Socket.IO for live updates, with snake draft
|
||||||
|
|
||||||
### 3.3 Client Hook
|
### 3.3 Client Hook
|
||||||
|
|
||||||
- Create `hooks/useDraftSocket.ts`
|
**File: `app/hooks/useDraftSocket.ts`**
|
||||||
- Handle connection/disconnection
|
|
||||||
- Provide methods to emit events
|
```typescript
|
||||||
- Provide listeners for events
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { io, Socket } from "socket.io-client";
|
||||||
|
|
||||||
|
export function useDraftSocket(seasonId: string) {
|
||||||
|
const socketRef = useRef<Socket | null>(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Connect to Socket.IO server
|
||||||
|
const socket = io({
|
||||||
|
path: "/socket.io",
|
||||||
|
transports: ["websocket", "polling"],
|
||||||
|
});
|
||||||
|
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
socket.on("connect", () => {
|
||||||
|
console.log("Connected to Socket.IO");
|
||||||
|
setIsConnected(true);
|
||||||
|
// Join the draft room
|
||||||
|
socket.emit("join-draft", seasonId);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("disconnect", () => {
|
||||||
|
console.log("Disconnected from Socket.IO");
|
||||||
|
setIsConnected(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
if (socket) {
|
||||||
|
socket.emit("leave-draft", seasonId);
|
||||||
|
socket.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [seasonId]);
|
||||||
|
|
||||||
|
// Helper to subscribe to events
|
||||||
|
const on = (event: string, callback: (...args: any[]) => void) => {
|
||||||
|
socketRef.current?.on(event, callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to unsubscribe from events
|
||||||
|
const off = (event: string, callback?: (...args: any[]) => void) => {
|
||||||
|
socketRef.current?.off(event, callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { socket: socketRef.current, isConnected, on, off };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Using Socket in Route Handlers
|
||||||
|
|
||||||
|
When a pick is made via HTTP POST, broadcast to all clients:
|
||||||
|
|
||||||
|
**Example in `app/routes/api/draft-pick.ts`:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getSocketIO } from "~/server/socket.js";
|
||||||
|
|
||||||
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
|
// ... validate and create pick ...
|
||||||
|
|
||||||
|
const pick = await createDraftPick({...});
|
||||||
|
|
||||||
|
// Broadcast to all clients in the draft room
|
||||||
|
const io = getSocketIO();
|
||||||
|
io.to(`draft-${seasonId}`).emit("pick-made", {
|
||||||
|
pick,
|
||||||
|
nextPickNumber,
|
||||||
|
isDraftComplete,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ success: true, pick });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 Testing Socket.IO
|
||||||
|
|
||||||
|
1. Start dev server: `npm run dev`
|
||||||
|
2. Check console for "Socket.IO initialized"
|
||||||
|
3. Open browser console on draft page
|
||||||
|
4. Should see "Connected to Socket.IO"
|
||||||
|
5. Check server logs for "Client connected: [socket-id]"
|
||||||
|
|
||||||
|
### 3.6 Gotchas & Important Notes
|
||||||
|
|
||||||
|
#### ⚠️ Gotcha #1: Import path in route handlers
|
||||||
|
```typescript
|
||||||
|
// ❌ WRONG - This won't work in React Router v7
|
||||||
|
import { getSocketIO } from "~/server/socket.js";
|
||||||
|
|
||||||
|
// ✅ CORRECT - Use relative path or configure alias
|
||||||
|
import { getSocketIO } from "../../../server/socket.js";
|
||||||
|
```
|
||||||
|
**Why:** React Router v7 runs server code, but `~` alias typically only works for app code. You may need to configure the import path or use relative imports.
|
||||||
|
|
||||||
|
#### ⚠️ Gotcha #2: Client hook creates new connection on every render
|
||||||
|
The current `useDraftSocket` hook will reconnect if `seasonId` changes. This is correct, but be aware:
|
||||||
|
- Don't call this hook multiple times in the same component tree
|
||||||
|
- Don't pass a changing `seasonId` unless you want to switch rooms
|
||||||
|
- Consider memoizing the `seasonId` prop
|
||||||
|
|
||||||
|
#### ⚠️ Gotcha #3: Event listener cleanup
|
||||||
|
```typescript
|
||||||
|
// ❌ WRONG - Listeners not cleaned up
|
||||||
|
useEffect(() => {
|
||||||
|
socket.on("pick-made", handlePickMade);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ✅ CORRECT - Clean up listeners
|
||||||
|
useEffect(() => {
|
||||||
|
socket.on("pick-made", handlePickMade);
|
||||||
|
return () => {
|
||||||
|
socket.off("pick-made", handlePickMade);
|
||||||
|
};
|
||||||
|
}, [handlePickMade]);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ⚠️ Gotcha #4: Socket.IO path in production
|
||||||
|
If you deploy behind a reverse proxy (nginx, etc.), you may need to configure the Socket.IO path:
|
||||||
|
```javascript
|
||||||
|
// Client
|
||||||
|
const socket = io({
|
||||||
|
path: "/socket.io", // Make sure this matches your proxy config
|
||||||
|
});
|
||||||
|
|
||||||
|
// Server (if needed)
|
||||||
|
const io = new SocketIOServer(httpServer, {
|
||||||
|
path: "/socket.io",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ⚠️ Gotcha #5: CORS might not be needed
|
||||||
|
Since Socket.IO runs on the same server/port as your app, CORS might be unnecessary for same-origin requests. You can simplify:
|
||||||
|
```javascript
|
||||||
|
// Simpler config if same-origin
|
||||||
|
const io = new SocketIOServer(httpServer);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ⚠️ Gotcha #6: Global storage in development
|
||||||
|
The global `__socketIO` pattern works, but in development with HMR, you might get multiple instances. The `if (io)` check helps, but be aware of potential stale connections during hot reloads.
|
||||||
|
|
||||||
|
#### ⚠️ Gotcha #7: TypeScript import in route handlers
|
||||||
|
```typescript
|
||||||
|
// This might cause issues because socket.js is JavaScript
|
||||||
|
import { getSocketIO } from "~/server/socket.js";
|
||||||
|
|
||||||
|
// Consider making it TypeScript with proper types
|
||||||
|
// Or use @ts-ignore if necessary
|
||||||
|
// @ts-ignore
|
||||||
|
import { getSocketIO } from "~/server/socket.js";
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.7 Production Considerations
|
||||||
|
|
||||||
|
- Socket.IO will automatically upgrade from polling to WebSocket
|
||||||
|
- Ensure load balancer supports WebSocket (sticky sessions required!)
|
||||||
|
- Consider Redis adapter for multi-server deployments (future)
|
||||||
|
- Monitor connection counts and memory usage
|
||||||
|
- Set up proper error handling for disconnections
|
||||||
|
- Consider implementing reconnection logic with exponential backoff
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
130
plans/phase-3-server-js-changes.md
Normal file
130
plans/phase-3-server-js-changes.md
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
# Phase 3: Exact Changes to server.js
|
||||||
|
|
||||||
|
## Current server.js (lines 1-10)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import compression from "compression";
|
||||||
|
import express from "express";
|
||||||
|
import morgan from "morgan";
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Add at top (after imports)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import compression from "compression";
|
||||||
|
import express from "express";
|
||||||
|
import morgan from "morgan";
|
||||||
|
import { createServer } from "http"; // ← ADD THIS LINE
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Current server.js (end of file)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
app.use(await import(BUILD_PATH).then((mod) => mod.app));
|
||||||
|
}
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server is running on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Replace with (end of file)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
app.use(await import(BUILD_PATH).then((mod) => mod.app));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ← ADD THESE LINES ↓
|
||||||
|
const httpServer = createServer(app);
|
||||||
|
|
||||||
|
// Initialize Socket.IO
|
||||||
|
const { initializeSocketIO } = await import("./server/socket.js");
|
||||||
|
initializeSocketIO(httpServer);
|
||||||
|
|
||||||
|
httpServer.listen(PORT, () => {
|
||||||
|
console.log(`Server is running on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
// ← END OF ADDITIONS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Diff
|
||||||
|
|
||||||
|
```diff
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
-app.listen(PORT, () => {
|
||||||
|
+const httpServer = createServer(app);
|
||||||
|
+
|
||||||
|
+// Initialize Socket.IO
|
||||||
|
+const { initializeSocketIO } = await import("./server/socket.js");
|
||||||
|
+initializeSocketIO(httpServer);
|
||||||
|
+
|
||||||
|
+httpServer.listen(PORT, () => {
|
||||||
|
console.log(`Server is running on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
**Lines Added:** 5
|
||||||
|
**Lines Changed:** 1
|
||||||
|
**Total Changes:** 6 lines
|
||||||
|
|
||||||
|
This is the absolute minimum change needed to support Socket.IO while keeping the existing architecture intact.
|
||||||
278
plans/phase-3-socket-implementation-plan.md
Normal file
278
plans/phase-3-socket-implementation-plan.md
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
### Option 1: Keep Socket.IO OUTSIDE the Bundle (Recommended) ✅
|
||||||
|
|
||||||
|
**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:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 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`**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Server as SocketIOServer } from "socket.io";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var __socketIO: SocketIOServer | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Update server.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
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)**
|
||||||
|
```typescript
|
||||||
|
// 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)**
|
||||||
|
```typescript
|
||||||
|
// 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
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# Check console for "Socket.IO initialized"
|
||||||
|
# Verify no build errors
|
||||||
|
# Test HMR still works
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Build Testing
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
```json
|
||||||
|
"build:socket": "tsc server/socket.ts --outDir dist",
|
||||||
|
"build": "npm run build:socket && react-router build"
|
||||||
|
```
|
||||||
|
|
||||||
|
But this adds complexity for minimal benefit.
|
||||||
|
|
||||||
|
## Recommended Approach
|
||||||
|
|
||||||
|
✅ **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.
|
||||||
111
plans/phase-3-socket-implementation-summary.md
Normal file
111
plans/phase-3-socket-implementation-summary.md
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
# Phase 3: Socket.IO Implementation - Summary
|
||||||
|
|
||||||
|
## Key Decision: Modify server.js (Option A)
|
||||||
|
|
||||||
|
After researching React Router v7 + Socket.IO integration patterns, the recommended approach is:
|
||||||
|
|
||||||
|
### Why This Approach?
|
||||||
|
|
||||||
|
1. **Industry Standard**: Socket.IO requires `http.createServer(app)` - this is the documented pattern
|
||||||
|
2. **Single Port**: HTTP and WebSocket traffic on same port (simpler deployment)
|
||||||
|
3. **No CORS Issues**: Everything runs on same origin
|
||||||
|
4. **Works with SSR**: Compatible with React Router v7's server-side rendering
|
||||||
|
5. **Simpler Deployment**: One process, one port, one service
|
||||||
|
|
||||||
|
### What Changes?
|
||||||
|
|
||||||
|
**Minimal changes to `server.js`:**
|
||||||
|
```javascript
|
||||||
|
// Before:
|
||||||
|
app.listen(PORT, () => {...});
|
||||||
|
|
||||||
|
// After:
|
||||||
|
import { createServer } from "http";
|
||||||
|
const httpServer = createServer(app);
|
||||||
|
const { initializeSocketIO } = await import("./server/socket.js");
|
||||||
|
initializeSocketIO(httpServer);
|
||||||
|
httpServer.listen(PORT, () => {...});
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it! Just 4 lines changed in server.js.
|
||||||
|
|
||||||
|
## Alternative Considered: Separate Socket Server (Option B)
|
||||||
|
|
||||||
|
**Why we rejected this:**
|
||||||
|
- Requires managing two processes
|
||||||
|
- CORS configuration complexity
|
||||||
|
- Different ports (3000 for HTTP, 3001 for WebSocket)
|
||||||
|
- More complex deployment (two services)
|
||||||
|
- Not the standard pattern
|
||||||
|
|
||||||
|
**When to use Option B:**
|
||||||
|
- If you absolutely cannot modify server.js
|
||||||
|
- If you need to scale WebSocket separately from HTTP
|
||||||
|
- If you're using a microservices architecture
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
### Step 1: Create Socket Module
|
||||||
|
- File: `server/socket.js` (not .ts to avoid bundling)
|
||||||
|
- Exports: `initializeSocketIO()` and `getSocketIO()`
|
||||||
|
- Stores instance globally for route handlers
|
||||||
|
|
||||||
|
### Step 2: Modify server.js
|
||||||
|
- Import `http.createServer`
|
||||||
|
- Create HTTP server from Express app
|
||||||
|
- Initialize Socket.IO with HTTP server
|
||||||
|
- Call `httpServer.listen()` instead of `app.listen()`
|
||||||
|
|
||||||
|
### Step 3: Client Hook
|
||||||
|
- Create `app/hooks/useDraftSocket.ts`
|
||||||
|
- Handles connection/disconnection
|
||||||
|
- Auto-joins draft room on connect
|
||||||
|
- Provides `on`/`off` helpers for events
|
||||||
|
|
||||||
|
### Step 4: Route Handlers
|
||||||
|
- Import `getSocketIO()` in API routes
|
||||||
|
- Emit events after database writes
|
||||||
|
- Example: After creating pick, emit `pick-made` to room
|
||||||
|
|
||||||
|
## Event Architecture
|
||||||
|
|
||||||
|
**Write Operations → HTTP POST**
|
||||||
|
- Making picks
|
||||||
|
- Queue management
|
||||||
|
- Commissioner actions
|
||||||
|
- Reason: Reliability, error handling, validation
|
||||||
|
|
||||||
|
**Read Operations → Socket.IO Broadcast**
|
||||||
|
- Pick made notifications
|
||||||
|
- Timer updates
|
||||||
|
- Draft status changes
|
||||||
|
- Reason: Real-time updates to all clients
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Server starts with "Socket.IO initialized"
|
||||||
|
- [ ] Browser console shows "Connected to Socket.IO"
|
||||||
|
- [ ] Server logs show "Client connected: [id]"
|
||||||
|
- [ ] Multiple clients can join same draft room
|
||||||
|
- [ ] Picks broadcast to all clients in room
|
||||||
|
- [ ] Disconnection handled gracefully
|
||||||
|
|
||||||
|
## Production Notes
|
||||||
|
|
||||||
|
- Socket.IO auto-upgrades from polling → WebSocket
|
||||||
|
- Load balancer needs sticky sessions for WebSocket
|
||||||
|
- Monitor connection counts
|
||||||
|
- Consider Redis adapter for horizontal scaling (future)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
**New Files:**
|
||||||
|
- `server/socket.js` - Socket.IO initialization
|
||||||
|
- `server/types.d.ts` - TypeScript globals
|
||||||
|
- `app/hooks/useDraftSocket.ts` - Client hook
|
||||||
|
|
||||||
|
**Modified Files:**
|
||||||
|
- `server.js` - 4 lines changed (http.createServer)
|
||||||
|
- `tsconfig.node.json` - Add server directory
|
||||||
|
|
||||||
|
**Total LOC:** ~150 lines of new code
|
||||||
Loading…
Add table
Reference in a new issue