Skip to content

Instantly share code, notes, and snippets.

@waltervargas
Last active March 15, 2026 00:48
Show Gist options
  • Select an option

  • Save waltervargas/cea798d0e81d0be9dcb337202796f817 to your computer and use it in GitHub Desktop.

Select an option

Save waltervargas/cea798d0e81d0be9dcb337202796f817 to your computer and use it in GitHub Desktop.
ADR-001: Consolidate Production Server Entry Point

ADR-001: Consolidate Production Server Entry Point

Status: Proposed Date: 2026-03-15 Author: Walter Deciders: Team

Background: The Technology Stack

What is Vite?

Vite is a build tool and development server for modern web applications. It does two very different things depending on the mode:

  • Development mode (vite dev): Runs a local HTTP server that serves your source files directly to the browser, transforming TypeScript, JSX, CSS, etc. on the fly. It uses Hot Module Replacement (HMR) so changes appear instantly without a full page reload. Crucially, Vite owns the HTTP server — your code runs inside Vite's process.

  • Build mode (vite build): Compiles and bundles your entire application into optimized static files (HTML, CSS, JS) for production. After the build completes, Vite exits — it is not running in production.

graph LR
    subgraph "vite dev (Development)"
        SRC[Source Files<br/>TypeScript, Svelte, CSS] -->|on-the-fly transform| VITE_DEV[Vite Dev Server<br/>owns the HTTP server]
        VITE_DEV -->|HMR| BROWSER[Browser]
    end

    subgraph "vite build (Production)"
        SRC2[Source Files] -->|compile + bundle| BUILD[build/ directory<br/>optimized static output]
        VITE_EXIT[Vite exits after build]
    end

    style VITE_DEV fill:#646cff,color:#fff
    style VITE_EXIT fill:#888,color:#fff
Loading

What is SvelteKit?

SvelteKit is a full-stack web framework built on top of Svelte (a UI component framework) and Vite. It provides:

  • File-based routing — files in src/routes/ become pages and API endpoints
  • Server-side rendering (SSR) — pages render on the server first, then hydrate on the client
  • API routes+server.ts files become REST endpoints
  • Adapters — plugins that package the built app for different deployment targets (Node.js, Vercel, Cloudflare, etc.)

Our project uses @sveltejs/adapter-node, which compiles the app into a Node.js-compatible format:

graph TB
    subgraph "SvelteKit Project Structure"
        ROUTES[src/routes/] -->|file-based routing| PAGES["Pages (+page.svelte)"]
        ROUTES --> API["API Routes (+server.ts)"]
        HOOKS[src/hooks.server.ts] -->|middleware| AUTH[Session validation]
        LIB[src/lib/] --> SHARED[Shared code<br/>components, utils, server modules]
    end

    subgraph "adapter-node Build Output"
        PAGES -->|vite build| CLIENT[build/client/<br/>static assets, JS, CSS]
        API -->|vite build| SERVER[build/server/<br/>compiled server code]
        HANDLER[build/handler.js<br/>Node.js request handler]
        SERVER --> HANDLER
    end

    subgraph "Production Runtime"
        ENTRY[server.js<br/>your custom entry point] -->|imports| HANDLER
        ENTRY -->|creates| HTTP[HTTP Server]
        HTTP -->|delegates requests| HANDLER
    end

    style HANDLER fill:#ff3e00,color:#fff
    style ENTRY fill:#51cf66,color:#fff
Loading

The key detail: adapter-node does NOT start a server. It exports a handler(req, res) function and leaves server creation to you. This is intentional — it lets you attach middleware, WebSockets, or any custom logic to the HTTP server.

How They Fit Together

graph TB
    subgraph "The Full Stack"
        SVELTE[Svelte<br/>UI component compiler] -->|used by| SK[SvelteKit<br/>full-stack framework]
        SK -->|built by| VITE[Vite<br/>build tool + dev server]
        SK -->|deployed via| ADAPTER[adapter-node<br/>Node.js adapter]
        ADAPTER -->|produces| HANDLER[handler function]
        HANDLER -->|consumed by| SERVER[server.js<br/>your production entry]
    end

    style SVELTE fill:#ff3e00,color:#fff
    style SK fill:#ff3e00,color:#fff
    style VITE fill:#646cff,color:#fff
    style ADAPTER fill:#339af0,color:#fff
Loading

Context

Why Dev and Production Differ: Who Owns the HTTP Server

The fundamental architectural split comes down to one question: who creates and controls the HTTP server?

graph TB
    subgraph "Development: Vite Owns the Server"
        V_START["vite dev starts"] --> V_HTTP["Vite creates HTTP server<br/>(you don't control this)"]
        V_HTTP --> V_SK["SvelteKit runs inside Vite<br/>via vite-plugin-svelte"]
        V_HTTP --> V_HMR["HMR + on-the-fly TypeScript"]
        V_HTTP --> V_PLUGIN["Your Vite plugin hooks in<br/>to attach Socket.IO"]

        V_PLUGIN -->|"server.httpServer.once('listening')"| V_SIO["Socket.IO attaches to<br/>Vite's HTTP server"]
    end

    subgraph "Production: You Own the Server"
        P_BUILD["vite build runs and exits"] --> P_OUT["build/handler.js produced"]
        P_START["node server.js starts"] --> P_HTTP["You create HTTP server<br/>(createServer)"]
        P_HTTP --> P_HANDLER["SvelteKit handler plugged in<br/>as request listener"]
        P_HTTP --> P_SIO["Socket.IO attached directly<br/>to your HTTP server"]
        P_HTTP --> P_AVATAR["Avatar middleware added<br/>before SvelteKit handler"]
    end

    style V_HTTP fill:#646cff,color:#fff
    style V_PLUGIN fill:#ffd43b,color:#333
    style P_HTTP fill:#51cf66,color:#fff
    style P_BUILD fill:#888,color:#fff
Loading

In development, Vite can compile TypeScript on the fly via ssrLoadModule(). The socket modules live in Vite's module graph, and emitters.ts accesses the Socket.IO instance directly via getIO() from the module scope.

In production, Vite is gone. The compiled SvelteKit code lives in build/server/ with internal chunk names you can't reliably import. The server.js entry point creates its own Socket.IO instance, but the compiled API routes can't import it directly — they're in a different module scope. That's why:

  1. server.js duplicates the socket logic in plain JS (because it can't import TypeScript)
  2. emitters.ts uses the globalThis bridge — it's the only way for compiled API routes to reach the Socket.IO instance that server.js created
sequenceDiagram
    participant SJS as server.js<br/>(your process)
    participant GT as globalThis<br/>(shared memory)
    participant API as API Route<br/>(compiled in build/)
    participant EM as emitters.ts<br/>(compiled in build/)

    Note over SJS: Process starts
    SJS->>SJS: const io = new SocketIOServer(httpServer)
    SJS->>SJS: const userSockets = new Map()
    SJS->>GT: globalThis.__socketIO = io
    SJS->>GT: globalThis.__userSockets = userSockets

    Note over API: User hits POST /api/friends/accept
    API->>EM: emitToUser(friendId, 'friend:accepted', data)
    EM->>GT: const io = globalThis.__socketIO
    EM->>GT: const sockets = globalThis.__userSockets
    Note over EM: Finds friend's socket IDs
    EM-->>EM: io.to(socketId).emit('friend:accepted', data)
Loading

This globalThis bridge is necessary because server.js and the compiled SvelteKit code run in the same Node.js process but in different module scopes — they can't import each other's variables, but they share the global object.

The Problem

We currently have two server entry points that have diverged:

Root server.js (production) src/server.js (unused)
Avatar uploads middleware Yes No
Socket.IO auth Inline duplication (~60 lines) Modular (socket/auth.ts)
Socket handlers All inline (~150 lines) Modular (handlers/*.ts)
globalThis exposure Yes No
Language Plain JS Plain JS (imports TS modules)

The root server.js duplicates logic that already exists in well-structured TypeScript modules under src/lib/server/socket/:

  • parseCookies() — duplicated from auth.ts
  • socketAuthMiddleware() — duplicated from auth.ts
  • getFriendIds() / notifyFriends() — duplicated from handlers/friends.ts
  • Game invite handlers — duplicated from handlers/game.ts
  • Presence tracking — duplicated from auth.ts

Why the duplication exists: The production server.js runs after build, so it cannot import .ts files from src/ directly. Rather than setting up a compilation step, the logic was copy-pasted into plain JS.

Current Architecture

graph TB
    subgraph "Development (vite dev)"
        VP[Vite Dev Server] --> VPlugin[socketIODevPlugin<br/>in vite.config.ts]
        VPlugin -->|ssrLoadModule<br/>compiles TS on the fly| SI[socket/index.ts]
        VPlugin -->|ssrLoadModule| SA[socket/auth.ts]
        VPlugin -->|ssrLoadModule| HF[handlers/friends.ts]
        VPlugin -->|ssrLoadModule| HG[handlers/game.ts]

        API_DEV[API Routes] -->|import| EM[emitters.ts]
        EM -->|"getIO() + userSockets<br/>(module scope)"| SI
    end

    subgraph "Production (node server.js)"
        SJS["server.js<br/>318 lines, plain JS"] -->|import handler| BH[build/handler.js]
        SJS -->|DUPLICATED inline| AUTH_DUP[parseCookies + authMiddleware]
        SJS -->|DUPLICATED inline| FRIEND_DUP[getFriendIds + notifyFriends]
        SJS -->|DUPLICATED inline| GAME_DUP[game invite handlers]
        SJS -->|DUPLICATED inline| PRESENCE_DUP[presence tracking]
        SJS -->|sets| GT["globalThis<br/>.__socketIO<br/>.__userSockets"]

        API_PROD[API Routes compiled] -->|import| EM_PROD[emitters.ts compiled]
        EM_PROD -->|reads globalThis fallback| GT
    end

    style AUTH_DUP fill:#ff6b6b,color:#fff
    style FRIEND_DUP fill:#ff6b6b,color:#fff
    style GAME_DUP fill:#ff6b6b,color:#fff
    style PRESENCE_DUP fill:#ff6b6b,color:#fff
Loading

Risks of Current Approach

  1. Divergence bugs — Dev and prod auth logic can silently diverge (dev uses Lucia ORM, prod uses raw SQL)
  2. Double maintenance — Every socket handler change must be mirrored in two places
  3. No type safety — Production server is untyped plain JS
  4. Growing monolithserver.js is already 318 lines and will grow with chat/tournament handlers

Decision

Compile a TypeScript production entry point (server.ts) using esbuild, producing a self-contained server.js that imports the same modular socket code used in development.

Why esbuild?

The problem is that server.js needs to use TypeScript modules from src/, but Node.js can't run TypeScript directly. We need a compiler — but only for the server entry point (SvelteKit already handles everything in src/routes/ and src/lib/ via vite build).

esbuild is ideal here because:

  • It bundles server.ts + all its src/lib/server/socket/ imports into a single JS file
  • It leaves node_modules (socket.io, postgres) and build/handler.js as runtime imports
  • It runs in ~50ms — negligible addition to the build pipeline
  • Vite already uses esbuild internally, so the project already depends on it transitively
graph LR
    subgraph "What esbuild bundles (your code)"
        ST[server.ts] --> IDX[socket/index.ts]
        ST --> AUTH[socket/auth.ts]
        ST --> FRIENDS[handlers/friends.ts]
        ST --> GAME[handlers/game.ts]
        ST --> UPLOADS[uploads-middleware.ts]
    end

    subgraph "What esbuild leaves external (runtime imports)"
        EXT1[build/handler.js]
        EXT2[socket.io]
        EXT3[postgres]
    end

    ST -->|"--external"| EXT1
    IDX -->|"--packages=external"| EXT2
    AUTH -->|"--packages=external"| EXT3

    ESBUILD["esbuild --bundle"] --> OUTPUT[server.js<br/>single compiled file]

    style OUTPUT fill:#51cf66,color:#fff
    style ESBUILD fill:#ffd43b,color:#333
Loading

Proposed Architecture

graph TB
    subgraph "Development (vite dev) — unchanged"
        VP[Vite Dev Server] --> VPlugin[socketIODevPlugin]
        VPlugin -->|ssrLoadModule| SI[socket/index.ts]
        VPlugin -->|ssrLoadModule| SA[socket/auth.ts]
        VPlugin -->|ssrLoadModule| HF[handlers/friends.ts]
        VPlugin -->|ssrLoadModule| HG[handlers/game.ts]

        API_DEV[API Routes] -->|import| EM[emitters.ts]
        EM -->|getIO + userSockets| SI
    end

    subgraph "Build Pipeline"
        VITE_BUILD[vite build] --> BUILD_DIR[build/handler.js<br/>build/client/<br/>build/server/]
        ESBUILD[esbuild src/server.ts] --> SERVER_JS[server.js compiled]

        SERVER_TS[src/server.ts] -->|imports same modules| SI2[socket/index.ts]
        SERVER_TS --> SA2[socket/auth.ts]
        SERVER_TS --> HF2[handlers/friends.ts]
        SERVER_TS --> HG2[handlers/game.ts]
        SERVER_TS --> UPLOADS[uploads-middleware.ts]
    end

    subgraph "Production (node server.js) — single source of truth"
        COMPILED[server.js compiled<br/>~40 lines of glue<br/>+ bundled socket modules] -->|runtime import| BH[build/handler.js]
        COMPILED -->|bundled inside| SOCKET[Socket.IO setup + handlers]
        COMPILED -->|bundled inside| AVATAR[Avatar middleware]
        COMPILED -->|sets| GT["globalThis<br/>.__socketIO<br/>.__userSockets"]

        API_PROD[API Routes compiled] -->|import| EM_PROD[emitters.ts compiled]
        EM_PROD -->|reads globalThis| GT
    end

    style SERVER_TS fill:#51cf66,color:#fff
    style COMPILED fill:#51cf66,color:#fff
    style SI2 fill:#51cf66,color:#fff
    style SA2 fill:#51cf66,color:#fff
    style HF2 fill:#51cf66,color:#fff
    style HG2 fill:#51cf66,color:#fff
    style UPLOADS fill:#51cf66,color:#fff
Loading

Build Flow

flowchart LR
    A["npm run build"] --> B["vite build<br/>(compiles SvelteKit app)"]
    A --> C["esbuild src/server.ts<br/>(compiles server entry)"]
    B --> D["build/<br/>handler.js + client/ + server/"]
    C --> E["server.js<br/>(single compiled file)"]

    F["node server.js<br/>(production start)"] --> E
    E -->|"import handler from<br/>'./build/handler.js'"| D

    style A fill:#ffd43b,color:#333
    style E fill:#51cf66,color:#fff
    style D fill:#339af0,color:#fff
Loading

Implementation

1. Rename and Rewrite the Entry Point

Create src/server.ts (replace existing src/server.js):

import { createServer } from 'http';
import { handler } from '../build/handler.js';
import { initSocketIO, userSockets } from './lib/server/socket/index.js';
import { socketAuthMiddleware, registerPresence } from './lib/server/socket/auth.js';
import { registerFriendHandlers } from './lib/server/socket/handlers/friends.js';
import { registerGameHandlers } from './lib/server/socket/handlers/game.js';
import { uploadsHandler } from './lib/server/uploads-middleware.js';

const PORT = process.env.PORT || 3000;
const HOST = process.env.HOST || '0.0.0.0';

const httpServer = createServer((req, res) => {
    uploadsHandler(req, res, () => handler(req, res));
});

const io = initSocketIO(httpServer);
io.use(socketAuthMiddleware);

io.on('connection', (socket) => {
    console.log(`[Socket.IO] User ${socket.data.userId} connected (${socket.id})`);
    registerPresence(socket);
    registerFriendHandlers(socket);
    registerGameHandlers(socket);

    socket.on('disconnect', () => {
        console.log(`[Socket.IO] User ${socket.data.userId} disconnected (${socket.id})`);
    });
});

// Expose for SvelteKit API routes (emitters.ts)
(globalThis as any).__userSockets = userSockets;
(globalThis as any).__socketIO = io;

httpServer.listen(PORT, HOST, () => {
    console.log(`[Server] Listening on http://${HOST}:${PORT}`);
    console.log('[Socket.IO] Attached to production server');
});

Result: ~40 lines instead of 318. Zero duplication.

2. Extract Avatar Middleware

Create src/lib/server/uploads-middleware.ts:

import { createReadStream } from 'fs';
import { stat } from 'fs/promises';
import { join, extname } from 'path';

const uploadsDir = join(import.meta.dirname ?? '', '..', 'build', 'client', 'avatars', 'uploads');

const MIME_TYPES: Record<string, string> = {
    '.webp': 'image/webp',
    '.png': 'image/png',
    '.jpg': 'image/jpeg',
    '.jpeg': 'image/jpeg',
};

export function uploadsHandler(req: any, res: any, next: () => void) {
    if (!req.url?.startsWith('/avatars/uploads/')) return next();

    const filename = req.url.slice('/avatars/uploads/'.length).split('?')[0];
    if (filename.includes('..') || filename.includes('/')) return next();

    const filepath = join(uploadsDir, filename);
    const ext = extname(filename).toLowerCase();
    const mime = MIME_TYPES[ext];
    if (!mime) return next();

    stat(filepath)
        .then((info) => {
            res.writeHead(200, {
                'Content-Type': mime,
                'Content-Length': info.size,
                'Cache-Control': 'public, max-age=31536000, immutable',
            });
            createReadStream(filepath).pipe(res);
        })
        .catch(() => next());
}

3. Add Build Script

npm install -D esbuild

Add to package.json:

{
    "scripts": {
        "build": "vite build && npm run build:server",
        "build:server": "esbuild src/server.ts --bundle --platform=node --format=esm --outfile=server.js --external:./build/handler.js --packages=external"
    }
}

Key flags:

  • --bundle — inlines all socket modules into a single file
  • --platform=node — Node.js target
  • --external:./build/handler.js — keeps the SvelteKit handler as a runtime import
  • --packages=external — keeps node_modules as runtime imports (socket.io, postgres, etc.)

4. Update Dockerfile

# No changes needed — `npm run build` already calls both steps
COPY server.js ./

5. Delete src/server.js

The old unused entry point is replaced by src/server.ts.

File Changes Summary

graph LR
    subgraph "Delete"
        D1["src/server.js<br/>(old unused entry)"]
    end

    subgraph "Create"
        C1["src/server.ts<br/>(new TypeScript entry)"]
        C2["src/lib/server/uploads-middleware.ts"]
    end

    subgraph "Modify"
        M1["package.json<br/>(add build:server script)"]
    end

    subgraph "Generated at Build Time"
        G1["server.js<br/>(compiled output, replaces old)"]
    end

    subgraph "Unchanged"
        N1["src/lib/server/socket/*<br/>(reused as-is)"]
        N2["vite.config.ts<br/>(dev plugin unchanged)"]
        N3["Dockerfile<br/>(already copies server.js)"]
    end

    style D1 fill:#ff6b6b,color:#fff
    style C1 fill:#51cf66,color:#fff
    style C2 fill:#51cf66,color:#fff
    style M1 fill:#ffd43b,color:#333
    style G1 fill:#74c0fc,color:#fff
Loading

Consequences

Positive

  • Single source of truth — socket logic lives only in src/lib/server/socket/
  • Type safety — production entry point is TypeScript
  • ~280 fewer lines — server entry goes from 318 to ~40 lines
  • No divergence risk — dev and prod use identical auth/handler code
  • Easier to extend — adding chat/tournament handlers = one import line

Negative

  • Extra build dependencyesbuild added as dev dependency (~8MB)
  • Two build stepsvite build + esbuild, though they run sequentially in one npm run build
  • import.meta.dirname resolution — needs testing to ensure uploadsDir resolves correctly after bundling

Neutral

  • globalThis pattern stays — still needed for emitters.ts dev/prod bridge
  • build/handler.js import stays — this is how adapter-node works, not a smell

Alternatives Considered

A. Extract to Plain .mjs Files

Move shared logic to plain JS files importable by both Vite and server.js.

Rejected: Loses TypeScript benefits and creates a third location for socket code.

B. Import from build/server/ Compiled Output

Have server.js import compiled modules from build/server/chunks/.

Rejected: SvelteKit's internal build paths are unstable and not part of the public API.

C. Use adapter-node entryPoint Option

Let SvelteKit compile the custom server as part of its own build.

Rejected: @sveltejs/adapter-node does not expose a stable entryPoint config. The documented pattern is exactly what we have: a separate server.js that imports the handler.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment