Custom Server (server.ts)
Open Genie uses a custom Node.js HTTP server with Hono as the API framework. The same server also handles WebSocket upgrade requests, so all traffic enters through a single port.
How It Works
The server creates a standard http.createServer instance and routes requests by path prefix:
/api/* → Hono handler (createApiApp() from apps/web/server/index.ts)
/ws → JWT-authenticated WebSocket (device connections)
/ws/iot → WebSocket for IoT hardware (ESP32)
all other paths → Vite SPA static files (dist/client/) with index.html fallback
WebSocket Connection Lifecycle
1. Upgrade Request
Device clients connect by requesting a WebSocket upgrade to /ws. The JWT can be passed as a query parameter or as a subprotocol:
GET /ws?token=eyJhbGciOiJIUzI1NiJ9... HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Alternatively, pass the token via the Sec-WebSocket-Protocol header using the opengenie.bearer.<token> pattern, and the server echoes back opengenie.v1.
2. Authentication
The server extracts and validates the access token via authenticateToken(). The JWT payload must contain:
interface AccessTokenPayload {
deviceId: string;
userId: string;
deviceType: string; // "phone" | "tablet" | "tv"
sessionId: string;
scope: "admin" | "member" | "guest";
}
If authentication fails, the server responds with 401 Unauthorized and destroys the socket. If the kill switch is active and the client IP is not LAN/loopback/tailnet, the socket is destroyed with 503 Service Unavailable before auth runs.
3. Connection Registration
On successful authentication, the device is registered in the global connection registry:
connections.register(deviceId, { ws, deviceId, userId, deviceType });
4. Heartbeat
The server pings each connected client every 30 seconds. If a client doesn't respond with a pong within 10 seconds, the connection is terminated.
Server → ping → Client
Client → pong → Server (alive = true)
// 30 seconds later...
Server → ping → Client
// No pong within 10s → terminate
5. Message Routing
All incoming messages are passed to the WebSocket router:
ws.on("message", (data) => {
router.route(data.toString(), ws);
});
See WebSocket Protocol for message format details.
6. Disconnection
On close, the server:
- Clears the heartbeat interval
- Unregisters the device from the connection registry
- Updates
lastSeentimestamp in the database
Boot Services
After the HTTP server starts listening, the following services are initialized:
| Service | Function | Failure Handling |
|---|---|---|
| Skills | loadSkills() | Logged, non-fatal |
| Plugins | reconcilePlugins() + startPluginWatcher() | Logged, non-fatal per plugin |
| Scheduler | scheduler.start() | Logged, non-fatal |
| Media Watcher | startMediaWatcher() + fullScan() | Logged, non-fatal |
| Camera Worker | cameraWorker.start() | Logged, non-fatal |
Each service is wrapped in a try/catch so that a failure in one doesn't prevent the others from starting.
Graceful Shutdown
The server listens for SIGTERM and SIGINT signals and performs an orderly shutdown:
const shutdown = () => {
scheduler.stop();
stopMediaWatcher();
cameraWorker.stop();
process.exit(0);
};
Key Files
| File | Purpose |
|---|---|
apps/web/server.ts | CLI entry point — delegates to lib/boot/start-server.ts |
apps/web/lib/boot/start-server.ts | startOpenGenie() — full boot sequence, HTTP + WS server |
apps/web/server/index.ts | createApiApp() — mounts all Hono route modules |
apps/web/server/routes/ | One file per API domain (cameras, chat, media, etc.) |
apps/web/lib/ws/connections.ts | Connection registry singleton |
apps/web/lib/ws/router.ts | Message routing by domain |
apps/web/lib/ws/handlers/ | Domain-specific message handlers |