Status: Proposed Date: 2026-03-15 Author: Walter Deciders: Team
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
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.tsfiles 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
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.
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
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
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:
server.jsduplicates the socket logic in plain JS (because it can't import TypeScript)emitters.tsuses theglobalThisbridge — it's the only way for compiled API routes to reach the Socket.IO instance thatserver.jscreated
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)
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.
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 fromauth.tssocketAuthMiddleware()— duplicated fromauth.tsgetFriendIds()/notifyFriends()— duplicated fromhandlers/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.
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
- Divergence bugs — Dev and prod auth logic can silently diverge (dev uses Lucia ORM, prod uses raw SQL)
- Double maintenance — Every socket handler change must be mirrored in two places
- No type safety — Production server is untyped plain JS
- Growing monolith —
server.jsis already 318 lines and will grow with chat/tournament handlers
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.
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 itssrc/lib/server/socket/imports into a single JS file - It leaves
node_modules(socket.io, postgres) andbuild/handler.jsas 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
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
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
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.
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());
}npm install -D esbuildAdd 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— keepsnode_modulesas runtime imports (socket.io, postgres, etc.)
# No changes needed — `npm run build` already calls both steps
COPY server.js ./The old unused entry point is replaced by src/server.ts.
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
- 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
- Extra build dependency —
esbuildadded as dev dependency (~8MB) - Two build steps —
vite build+esbuild, though they run sequentially in onenpm run build import.meta.dirnameresolution — needs testing to ensureuploadsDirresolves correctly after bundling
globalThispattern stays — still needed foremitters.tsdev/prod bridgebuild/handler.jsimport stays — this is how adapter-node works, not a smell
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.
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.
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.