import type { ServerWebSocket, Socket } from 'bun'; import { createSidecarConnector } from '@@/sidecar/connect'; import { API_URL } from '@@/officer-url.mjs'; import { VNC_SOCKET, ensureDirs } from './paths'; import { isRunning, start, status, stop } from './session'; /* officer-minesweeper — owns the game session, and serves both halves of the plugin from one listener. ───────────────────────────────────────────────────────────────────────────────────────────────── HTTP CONTRACT — the platform strips its /api/minesweeper mount prefix before forwarding. GET /status { installed, running, window, error? } POST /start brings the session up; idempotent, and builds the wine prefix on a cold install POST /stop takes it down GET /ws the display — an RFB stream, relayed frame-for-byte onto x11vnc's unix socket ───────────────────────────────────────────────────────────────────────────────────────────────── WHY ONE PORT SERVES BOTH A sidecar announces exactly one port, and that single announcement is the entire reason the platform trusts it: the port was observed arriving at registration rather than asserted by a manifest. So `/ws` lives here, beside the routes, on the listener that was announced — and the platform's plugin socket relay moves frames onto it without knowing that RFB exists. The conversion from frames to a byte stream happens HERE, in the only process that knows what it is carrying. That is the whole shape: the platform authenticates and relays; the plugin speaks its protocol. ───────────────────────────────────────────────────────────────────────────────────────────────── WHAT REACHES x11vnc Nothing but this file. x11vnc binds a socket inside a mode-0700 directory, so the kernel refuses every other account before any credential could be checked. That is why `-nopw` is right rather than lazy — see the block comment in `session.ts` where it is spawned. This paragraph used to end "and has no port at all", and that was false for as long as it existed: `-unixsock` ADDS a unix socket, it does not remove the TCP listener, so x11vnc was on 0.0.0.0:5900 with no password. `-rfbport 0` is what makes the sentence true, and it is in `session.ts` for that reason. This listener is loopback and unauthenticated, exactly like every other sidecar in the fleet; the platform is the authenticator and `X-Officer-User` is how a request proves it came through one. */ const NAME = 'minesweeper'; const log = (line: string) => console.log(`[${NAME}] ${line}`); ensureDirs(); /** An ephemeral free port, grabbed by briefly binding one. Announced at registration; never written down. */ function getFreePort(): number { const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); const p = probe.port; probe.stop(true); if (p == null) throw new Error('failed to acquire a free port'); return p; } const port = getFreePort(); type WsData = { userId: number }; type Relay = { socket: Socket | null; queue: Uint8Array[]; done: boolean }; const relays = new Map, Relay>(); const teardown = (ws: ServerWebSocket, code: number, reason: string) => { const relay = relays.get(ws); relays.delete(ws); if (relay) relay.done = true; try { relay?.socket?.end(); } catch { /* already gone */ } try { ws.close(code, reason); } catch { /* already closing */ } }; const server = Bun.serve({ port, hostname: '127.0.0.1', async fetch(req, srv) { const url = new URL(req.url); // Loopback plus this header is the trust signal every sidecar here uses: a request without it did not // come through the platform, and the platform is what decided the caller may play. const officerUser = req.headers.get('X-Officer-User'); const userId = Number(officerUser); if (!officerUser || !Number.isInteger(userId) || userId <= 0) { return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 }); } if (url.pathname === '/ws') { if (srv.upgrade(req, { data: { userId } satisfies WsData })) return undefined as unknown as Response; return new Response('expected a websocket upgrade', { status: 426 }); } try { if (req.method === 'GET' && url.pathname === '/status') return Response.json(await status()); if (req.method === 'POST' && url.pathname === '/start') { const result = await start(log); // 503 rather than 500: the session is unavailable and the reason is in the body, which is what the // panel shows instead of an empty canvas. return Response.json(result, { status: result.running ? 200 : 503 }); } if (req.method === 'POST' && url.pathname === '/stop') return Response.json(await stop()); } catch (err) { console.error(`[${NAME}] ${req.method} ${url.pathname} failed`, err); return Response.json({ error: 'internal error' }, { status: 500 }); } return Response.json({ error: 'not found' }, { status: 404 }); }, websocket: { open(ws: ServerWebSocket) { /* The queue is not an optimisation. The client is connected the moment the upgrade returns and an RFB client sends its protocol version immediately, while `Bun.connect` is still resolving. Dropping those bytes desynchronises the handshake in a way that renders as a corrupt display rather than as a lost packet. */ const relay: Relay = { socket: null, queue: [], done: false }; relays.set(ws, relay); if (!isRunning()) { teardown(ws, 1011, 'the session is not running'); return; } void Bun.connect({ unix: VNC_SOCKET, socket: { open(socket) { if (relay.done) { socket.end(); return; } relay.socket = socket; for (const chunk of relay.queue) socket.write(chunk); relay.queue.length = 0; }, data(_socket, chunk) { try { ws.send(chunk); } catch { /* viewer gone; close tidies up */ } }, close: () => teardown(ws, 1000, 'display closed'), error: () => teardown(ws, 1011, 'display error'), }, }).catch(() => teardown(ws, 1011, 'could not reach the display')); }, message(ws: ServerWebSocket, raw: string | Buffer) { const relay = relays.get(ws); if (!relay) return; // RFB is binary both ways; a text frame is a client that is not speaking it. const bytes = typeof raw === 'string' ? new TextEncoder().encode(raw) : new Uint8Array(raw); if (relay.socket) relay.socket.write(bytes); else relay.queue.push(bytes); }, close(ws: ServerWebSocket) { const relay = relays.get(ws); relays.delete(ws); if (relay) relay.done = true; try { relay?.socket?.end(); } catch { /* already gone */ } }, }, }); log(`listening on 127.0.0.1:${port}`); const connection = createSidecarConnector({ apiUrl: `${API_URL}/api/sidecar/register`, name: NAME, handles: [NAME], onCommand(cmd, reply) { const c = cmd as { type: string; id?: string }; if (c.type === 'ping') reply({ type: 'pong', id: c.id } as never); else reply({ type: 'error', id: c.id, error: `Unknown command type: ${c.type}` } as never); }, onConnected() { // The announcement the plugin websocket relay resolves against. One port, both halves. connection.send({ type: `${NAME}:server`, port } as never); log(`reported server port ${port} to the platform`); }, }); async function shutdown(signal: string) { log(`${signal} received, shutting down`); try { server.stop(true); } catch { /* already stopped */ } // The game is not left running. It is a child of this process, so PM2 would reach it anyway — but wine's // own daemons are not, and leaving those holding the prefix is what makes the NEXT start hang. await stop().catch(() => undefined); connection.destroy(); process.exit(0); } process.on('SIGTERM', () => void shutdown('SIGTERM')); process.on('SIGINT', () => void shutdown('SIGINT'));