Skip to main content

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:

  1. Clears the heartbeat interval
  2. Unregisters the device from the connection registry
  3. Updates lastSeen timestamp in the database

Boot Services

After the HTTP server starts listening, the following services are initialized:

ServiceFunctionFailure Handling
SkillsloadSkills()Logged, non-fatal
PluginsreconcilePlugins() + startPluginWatcher()Logged, non-fatal per plugin
Schedulerscheduler.start()Logged, non-fatal
Media WatcherstartMediaWatcher() + fullScan()Logged, non-fatal
Camera WorkercameraWorker.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

FilePurpose
apps/web/server.tsCLI entry point — delegates to lib/boot/start-server.ts
apps/web/lib/boot/start-server.tsstartOpenGenie() — full boot sequence, HTTP + WS server
apps/web/server/index.tscreateApiApp() — mounts all Hono route modules
apps/web/server/routes/One file per API domain (cameras, chat, media, etc.)
apps/web/lib/ws/connections.tsConnection registry singleton
apps/web/lib/ws/router.tsMessage routing by domain
apps/web/lib/ws/handlers/Domain-specific message handlers