Files
platform/src/servers/api/minesweeper/websocket.ts
T
pastilhasandClaude Fable 5 af918251d3 /minesweeper — a wine session streamed to the browser over RFB
a websocket byte relay onto x11vnc's tcp socket, the same shape as the
terminal's relay onto the pty sidecar. this is the piece plugins/desktop
could not have: a plugin cannot claim a websocket provider.

the rfb client is ours rather than vendored novnc — raw and copyrect,
one pixel format we dictate so a rectangle memcpys into ImageData. we
control the server, so the other eight encodings are surface we would
carry and never run.

execution, so owner-only: wine runs as the owner, which makes the game's
menus file dialogs over the owner's home.

two bugs worth keeping. the token key is BEARER_TOKEN, not token, so the
upgrade 401'd before any rfb byte moved. and the /status health check
opened a tcp connection to prove liveness — libvncserver blocks its
accept loop peeking for a websocket handshake, so a polled health check
filled the backlog and locked out the real viewer. a liveness check must
not be indistinguishable from a client; it reads /proc/net/tcp now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:39:35 +00:00

96 lines
3.2 KiB
TypeScript

import type { ServerWebSocket, Socket } from 'bun';
import { VNC_HOST, VNC_PORT } from './session';
/**
* A byte relay between the browser and x11vnc, in the same spirit as the terminal's relay onto the pty
* sidecar — this file authenticates the upgrade (upgradeWs, in server.tsx) and moves bytes without
* reading them.
*
* One difference decides the shape: x11vnc serves raw TCP, not a WebSocket. So this is the job
* websockify would otherwise do, done in-process with `Bun.connect` rather than by adding a second
* daemon and a second port to protect. RFB is a stream protocol, so frame boundaries carry no meaning
* and chunks are forwarded exactly as they arrive.
*/
export type MinesweeperWSData = { provider: 'minesweeper'; search: string };
type Relay = { socket: Socket | null; queue: Uint8Array[]; done: boolean };
const relays = new Map<ServerWebSocket<MinesweeperWSData>, Relay>();
const closeClient = (ws: ServerWebSocket<MinesweeperWSData>, 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 closed */
}
};
export const minesweeperWebsocket = {
open(ws: ServerWebSocket<MinesweeperWSData>) {
// Registered synchronously, so bytes arriving before the TCP connection is up are queued rather
// than dropped. The RFB handshake starts with the SERVER talking, but a client that pipelines its
// version string would otherwise lose it.
const relay: Relay = { socket: null, queue: [], done: false };
relays.set(ws, relay);
void Bun.connect({
hostname: VNC_HOST,
port: VNC_PORT,
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 {
/* client gone; the close handler tears down the socket */
}
},
close: () => closeClient(ws, 1000, 'vnc closed'),
error: () => closeClient(ws, 1011, 'vnc error'),
},
}).catch(() => {
// No x11vnc listening — the session is not running. 1011 rather than a silent close, so the
// panel can say so instead of showing an empty canvas.
closeClient(ws, 1011, 'minesweeper session is not running');
});
},
message(ws: ServerWebSocket<MinesweeperWSData>, raw: string | Buffer) {
const relay = relays.get(ws);
if (!relay) return;
// RFB is binary in both directions; 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<MinesweeperWSData>) {
const relay = relays.get(ws);
relays.delete(ws);
if (relay) relay.done = true;
try {
relay?.socket?.end();
} catch {
/* ignore */
}
},
};