minesweeper leaves the platform; plugin sockets relay ws-to-ws
Minesweeper is now plugins/minesweeper (gitea.officer.dev/plugins/minesweeper). Removes /api/minesweeper, the Dashboard screen, the officerdev panel, the permission registry entry, the App.tsx route and the hand-written websocket provider in server.tsx. Registry entries: 22 -> 21, execution 4 -> 3. The relay changed transport, and that is the part worth reading. A sidecar announces exactly one port. That single announcement is the whole provenance argument in target.ts — the platform watched the port arrive at registration, which is the only thing separating it from a number a manifest asserted. But a raw-TCP relay requires that port to BE a raw TCP listener, so the sidecar cannot also serve HTTP on it, and the plugin's ordinary routes have to fall back to the typed command vocabulary over the registration socket. That is the mechanism the fleet has spent months retiring, and Minesweeper would have been the first thing to go back to it. Relaying websocket-to-websocket dissolves the conflict. The sidecar serves HTTP and /ws on the one port it announced, exactly as the pty sidecar already does; createSidecarProxy handles the HTTP half; and the conversion from frames to whatever the plugin actually speaks happens inside the plugin, which is the only process that knows. The relay is still ignorant of the payload — the ignorance sits one layer up. Also: the caller's JWT is no longer forwarded upstream. A sidecar has no use for it and forwarding one puts a bearer token into whatever that process logs. X-Officer-User carries the only part it needs. (The terminal relay still forwards its token to the pty sidecar; not touched here.) Verified: bunx tsgo clean, bun test 1403 pass / 0 fail, bun run docs:check green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -1,28 +0,0 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { sessionStatus, startSession, stopSession } from './session';
|
||||
|
||||
/**
|
||||
* Session control. The pixels do not come through here — they come through the websocket relay; this
|
||||
* is only "is it up, bring it up, take it down", plus the window size the panel needs to size its
|
||||
* canvas.
|
||||
*/
|
||||
export const minesweeperRouter = createRouter();
|
||||
|
||||
minesweeperRouter.get('/status', async (c) => c.json(await sessionStatus()));
|
||||
|
||||
minesweeperRouter.post('/start', async (c) => {
|
||||
const status = await sessionStatus();
|
||||
if (!status.installed) {
|
||||
return c.json({ error: 'Minesweeper is not installed on this server (wine-minesweeper is missing)' }, 501);
|
||||
}
|
||||
if (status.running) return c.json(status);
|
||||
|
||||
const result = await startSession();
|
||||
if (!result.ok) return c.json({ error: `Could not start the session: ${result.out.trim()}` }, 500);
|
||||
return c.json(await sessionStatus());
|
||||
});
|
||||
|
||||
minesweeperRouter.post('/stop', async (c) => {
|
||||
await stopSession();
|
||||
return c.json(await sessionStatus());
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import { join } from 'node:path';
|
||||
import { OFFICER_ROOT } from '../../data-path';
|
||||
|
||||
/**
|
||||
* The Minesweeper X session: a virtual display with one Windows game on it, and x11vnc serving that
|
||||
* display on loopback.
|
||||
*
|
||||
* The platform does not implement any of that — it drives `bin/session.sh`, which is the same script
|
||||
* used by hand. One implementation, so a session started from the browser and one started from a
|
||||
* terminal are the same session with the same lifecycle.
|
||||
*/
|
||||
|
||||
export const MINESWEEPER_DIR = join(OFFICER_ROOT, 'wine-minesweeper');
|
||||
const SESSION_SCRIPT = join(MINESWEEPER_DIR, 'bin', 'session.sh');
|
||||
|
||||
export const VNC_HOST = '127.0.0.1';
|
||||
export const VNC_PORT = Number(process.env.MINESWEEPER_VNC_PORT ?? 5907);
|
||||
const DISPLAY = process.env.MINESWEEPER_DISPLAY ?? ':7';
|
||||
|
||||
export type WindowGeometry = { width: number; height: number; x: number; y: number };
|
||||
export type SessionStatus = {
|
||||
installed: boolean;
|
||||
running: boolean;
|
||||
/** Absent until the game has drawn its window. */
|
||||
window: WindowGeometry | null;
|
||||
};
|
||||
|
||||
const run = async (cmd: string[], env: Record<string, string> = {}): Promise<{ ok: boolean; out: string }> => {
|
||||
const proc = Bun.spawn(cmd, {
|
||||
cwd: MINESWEEPER_DIR,
|
||||
env: { ...process.env, ...env },
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
const code = await proc.exited;
|
||||
return { ok: code === 0, out: `${out}${err}` };
|
||||
};
|
||||
|
||||
export const isInstalled = (): boolean => Bun.file(SESSION_SCRIPT).size > 0;
|
||||
|
||||
/**
|
||||
* Whether x11vnc is listening, decided by reading the kernel's socket table rather than by connecting.
|
||||
*
|
||||
* This used to open a TCP connection and drop it. That wedged the VNC server: libvncserver peeks at a
|
||||
* new connection to decide whether it is a WebSocket handshake, and that peek blocks its main loop
|
||||
* until the client speaks or the peek times out. A health check that connects and says nothing is
|
||||
* therefore a small stall, and the panel polls status on a timer — so the stalls overlapped, the accept
|
||||
* backlog filled (Recv-Q 32/32), and the server stopped accepting the real viewer too.
|
||||
*
|
||||
* The rule it cost to learn: a liveness check must not be indistinguishable from a client. Reading
|
||||
* /proc/net/tcp is free, cannot block, and cannot be mistaken for a viewer.
|
||||
*/
|
||||
async function isServing(): Promise<boolean> {
|
||||
const wanted = VNC_PORT.toString(16).toUpperCase().padStart(4, '0');
|
||||
const LISTEN = '0A';
|
||||
|
||||
for (const table of ['/proc/net/tcp', '/proc/net/tcp6']) {
|
||||
const text = await Bun.file(table)
|
||||
.text()
|
||||
.catch(() => '');
|
||||
|
||||
for (const line of text.split('\n').slice(1)) {
|
||||
const columns = line.trim().split(/\s+/);
|
||||
const local = columns[1];
|
||||
const state = columns[3];
|
||||
if (!local || state !== LISTEN) continue;
|
||||
if (local.split(':')[1] === wanted) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The game window's size, so the browser can size its canvas to the board rather than to the whole
|
||||
* framebuffer. `session.sh` parks the window at 0,0, so the offset is expected to be zero and is
|
||||
* reported anyway — a non-zero one means something moved it and the canvas would be misaligned.
|
||||
*
|
||||
* The board changes size when the difficulty changes, which is why this is polled rather than read
|
||||
* once at connect.
|
||||
*/
|
||||
export async function windowGeometry(): Promise<WindowGeometry | null> {
|
||||
const search = await run(['xdotool', 'search', '--name', 'Minesweeper X'], { DISPLAY });
|
||||
if (!search.ok) return null;
|
||||
|
||||
let best: WindowGeometry | null = null;
|
||||
for (const id of search.out.trim().split('\n').filter(Boolean)) {
|
||||
const geom = await run(['xdotool', 'getwindowgeometry', '--shell', id], { DISPLAY });
|
||||
if (!geom.ok) continue;
|
||||
|
||||
const read = (key: string): number => Number(new RegExp(`^${key}=(\\d+)$`, 'm').exec(geom.out)?.[1] ?? NaN);
|
||||
const candidate = { width: read('WIDTH'), height: read('HEIGHT'), x: read('X'), y: read('Y') };
|
||||
if (Object.values(candidate).some(Number.isNaN)) continue;
|
||||
|
||||
// wine creates a 1x1 owner stub alongside the real window; the larger one is the game.
|
||||
if (!best || candidate.width * candidate.height > best.width * best.height) best = candidate;
|
||||
}
|
||||
return best && best.width > 1 ? best : null;
|
||||
}
|
||||
|
||||
export async function sessionStatus(): Promise<SessionStatus> {
|
||||
const installed = isInstalled();
|
||||
if (!installed) return { installed, running: false, window: null };
|
||||
|
||||
const running = await isServing();
|
||||
return { installed, running, window: running ? await windowGeometry() : null };
|
||||
}
|
||||
|
||||
export const startSession = (): Promise<{ ok: boolean; out: string }> => run([SESSION_SCRIPT, 'start']);
|
||||
export const stopSession = (): Promise<{ ok: boolean; out: string }> => run([SESSION_SCRIPT, 'stop']);
|
||||
@@ -1,95 +0,0 @@
|
||||
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 */
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -4,10 +4,9 @@ import { snapshotPlugins } from '@@/plugins/mount';
|
||||
/*
|
||||
Where a plugin's socket actually goes.
|
||||
|
||||
A target is a TCP address and never a URL, and that is what keeps the relay ignorant of the protocol on
|
||||
the other side. The ignorance is the point: it is the only reason one relay can serve an RFB display and a
|
||||
line-oriented sidecar without knowing which is which. The moment this returned a scheme or a path, the
|
||||
relay would have to care what it was carrying.
|
||||
A target is an ADDRESS — a host and a port — never a URL a manifest wrote. The relay turns it into
|
||||
`ws://<host>:<port>/ws`, and `/ws` is a convention rather than a setting for the same reason the host is
|
||||
not configurable: every value here has to be one the platform derived, not one a plugin asserted.
|
||||
|
||||
The target is always a SIDECAR. A fixed-port form was written and removed the same day: nothing stops a
|
||||
manifest naming a port it does not own, so `{ port: 5432 }` plus a grant would be a raw TCP relay onto
|
||||
@@ -15,7 +14,10 @@ import { snapshotPlugins } from '@@/plugins/mount';
|
||||
to the plugin, and only the second question matters.
|
||||
|
||||
A sidecar's port is different in kind, not degree: the platform watched it arrive at registration, so the
|
||||
association is observed rather than asserted.
|
||||
association is observed rather than asserted. It is also the SAME port the sidecar serves its HTTP on —
|
||||
one announcement, one listener, `/ws` alongside the routes. That is what the relay's move to
|
||||
websocket-to-websocket bought, and why a plugin wanting a socket no longer has to choose between having
|
||||
one and having ordinary routes.
|
||||
|
||||
**Host is always loopback and is not configurable.** A plugin socket that could name an arbitrary host
|
||||
would be a request-forgery primitive handed to every plugin author and reachable by anyone the plugin is
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ServerWebSocket, Socket } from 'bun';
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import { resolveAuthToken } from '@@/auth-token';
|
||||
import { isTokenBlacklisted } from 'officerdb';
|
||||
import { isApiRequestAllowed } from '@@/permissions/authorize';
|
||||
@@ -19,26 +19,37 @@ import { pluginWsTargetFor } from './target';
|
||||
dynamic — one wildcard declared at boot can serve every plugin that will ever be installed, because the
|
||||
plugin name is a path segment rather than a route.
|
||||
|
||||
── What it does and does not read ──
|
||||
── The upstream is a WEBSOCKET, not a raw TCP socket ──
|
||||
|
||||
Bytes, both ways, without interpreting them. Same as the terminal's relay onto the pty sidecar and the
|
||||
minesweeper relay onto x11vnc: frame boundaries carry no meaning to a stream protocol, so chunks are
|
||||
forwarded exactly as they arrive.
|
||||
This relayed onto a raw TCP address for about a day. It was changed while extracting Minesweeper, and the
|
||||
reason is worth keeping because it is not a preference.
|
||||
|
||||
A plugin's target is a TCP address, never a URL. That is deliberate — it keeps this file ignorant of
|
||||
whatever protocol is on the other side, which is the only reason one relay can serve an RFB display and a
|
||||
line-oriented sidecar without knowing which is which.
|
||||
**A sidecar announces exactly one port.** That is the whole provenance argument for `target.ts` — the
|
||||
platform watched that port arrive at registration, which is the only thing separating it from a number a
|
||||
manifest asserted. But if this relay needs raw TCP, that one port has to BE a raw TCP listener, so the
|
||||
sidecar cannot also serve HTTP on it, and the plugin's ordinary routes have to fall back to the typed
|
||||
command vocabulary over the registration socket — the mechanism the fleet has spent months retiring.
|
||||
|
||||
Relaying websocket-to-websocket dissolves that. The sidecar serves HTTP and `/ws` on the single port it
|
||||
announced, exactly as the pty sidecar already does; `createSidecarProxy` handles the HTTP half; and the
|
||||
conversion from frames to whatever the plugin actually speaks happens INSIDE the plugin, which is the only
|
||||
process that knows what that is. Minesweeper's x11vnc is the worked example: the sidecar owns the RFB
|
||||
stream and the platform never learns that RFB exists.
|
||||
|
||||
This file is still ignorant of the payload — it moves frames without reading them, and a stream protocol's
|
||||
boundaries carry no meaning either way. The ignorance simply sits one layer up.
|
||||
*/
|
||||
|
||||
export type PluginWSData = {
|
||||
provider: 'plugin-ws';
|
||||
plugin: string;
|
||||
host: string;
|
||||
port: number;
|
||||
/** `ws://127.0.0.1:<announced port>/ws`, with the client's query minus its credential. */
|
||||
upstream: string;
|
||||
userId: number;
|
||||
};
|
||||
|
||||
type Relay = { socket: Socket | null; queue: Uint8Array[]; done: boolean };
|
||||
type Frame = string | Uint8Array<ArrayBuffer>;
|
||||
type Relay = { ws: WebSocket | null; queue: Frame[]; done: boolean };
|
||||
const relays = new Map<ServerWebSocket<PluginWSData>, Relay>();
|
||||
|
||||
const closeClient = (ws: ServerWebSocket<PluginWSData>, code: number, reason: string) => {
|
||||
@@ -46,7 +57,7 @@ const closeClient = (ws: ServerWebSocket<PluginWSData>, code: number, reason: st
|
||||
relays.delete(ws);
|
||||
if (relay) relay.done = true;
|
||||
try {
|
||||
relay?.socket?.end();
|
||||
relay?.ws?.close();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
@@ -58,55 +69,63 @@ const closeClient = (ws: ServerWebSocket<PluginWSData>, code: number, reason: st
|
||||
};
|
||||
|
||||
export const pluginWsWebsocket = {
|
||||
async open(ws: ServerWebSocket<PluginWSData>) {
|
||||
open(ws: ServerWebSocket<PluginWSData>) {
|
||||
/*
|
||||
The queue is not an optimisation. Bun requires `server.upgrade` to be synchronous, so the client is
|
||||
already connected and may already be talking by the time `Bun.connect` resolves — and an RFB client
|
||||
sends its protocol version immediately. Dropping those bytes desynchronises the stream in a way that
|
||||
looks like a corrupt display rather than a lost packet. Learned by dev-proxy first.
|
||||
already connected and may already be talking before the upstream is open — and an RFB client sends its
|
||||
protocol version immediately. Dropping those frames desynchronises the stream in a way that looks like
|
||||
a corrupt display rather than a lost packet. Learned by dev-proxy first.
|
||||
*/
|
||||
const relay: Relay = { socket: null, queue: [], done: false };
|
||||
const relay: Relay = { ws: null, queue: [], done: false };
|
||||
relays.set(ws, relay);
|
||||
|
||||
let upstream: WebSocket;
|
||||
try {
|
||||
const socket = await Bun.connect({
|
||||
hostname: ws.data.host,
|
||||
port: ws.data.port,
|
||||
socket: {
|
||||
data: (_s, chunk) => {
|
||||
try {
|
||||
ws.send(chunk);
|
||||
} catch {
|
||||
/* client gone; the close handler tidies up */
|
||||
}
|
||||
},
|
||||
close: () => closeClient(ws, 1011, 'upstream closed'),
|
||||
error: () => closeClient(ws, 1011, 'upstream error'),
|
||||
},
|
||||
});
|
||||
// The sidecar binds loopback and is told who is calling, the same signal every HTTP sidecar trusts:
|
||||
// a request without this header did not come through the platform.
|
||||
upstream = new WebSocket(ws.data.upstream, { headers: { 'X-Officer-User': String(ws.data.userId) } } as never);
|
||||
} catch {
|
||||
closeClient(ws, 1011, `${ws.data.plugin} is not accepting connections`);
|
||||
return;
|
||||
}
|
||||
relay.ws = upstream;
|
||||
upstream.binaryType = 'arraybuffer';
|
||||
|
||||
// Between the await above and here the client may have gone. Writing to a socket nobody is reading
|
||||
// would keep the upstream connection open for as long as the plugin's process lives.
|
||||
upstream.onopen = () => {
|
||||
// Between the upgrade and here the client may have gone. Sending into a socket nobody reads would hold
|
||||
// the upstream connection open for as long as the plugin's process lives.
|
||||
if (relay.done) {
|
||||
socket.end();
|
||||
try {
|
||||
upstream.close();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
return;
|
||||
}
|
||||
relay.socket = socket;
|
||||
for (const chunk of relay.queue) socket.write(chunk);
|
||||
for (const frame of relay.queue) upstream.send(frame);
|
||||
relay.queue.length = 0;
|
||||
} catch {
|
||||
// A plugin whose process is not listening is the ordinary case, not an exception: a sidecar that has
|
||||
// not booted yet, or a plugin whose service is stopped. The client is told, and told which plugin.
|
||||
closeClient(ws, 1011, `${ws.data.plugin} is not accepting connections`);
|
||||
}
|
||||
};
|
||||
|
||||
upstream.onmessage = (ev) => {
|
||||
try {
|
||||
ws.send(typeof ev.data === 'string' ? ev.data : new Uint8Array(ev.data as ArrayBuffer));
|
||||
} catch {
|
||||
/* client gone; the close handler tidies up */
|
||||
}
|
||||
};
|
||||
|
||||
// A plugin whose process is not listening is the ordinary case rather than an exception: a sidecar that
|
||||
// has not booted, or a service the owner stopped. The client is told, and told which plugin.
|
||||
upstream.onerror = () => closeClient(ws, 1011, `${ws.data.plugin} is not accepting connections`);
|
||||
upstream.onclose = () => closeClient(ws, 1011, 'upstream closed');
|
||||
},
|
||||
|
||||
message(ws: ServerWebSocket<PluginWSData>, message: string | Uint8Array) {
|
||||
message(ws: ServerWebSocket<PluginWSData>, message: string | Buffer) {
|
||||
const relay = relays.get(ws);
|
||||
if (!relay) return;
|
||||
const bytes = typeof message === 'string' ? new TextEncoder().encode(message) : message;
|
||||
if (relay.socket) relay.socket.write(bytes);
|
||||
else relay.queue.push(bytes);
|
||||
const frame: Frame = typeof message === 'string' ? message : (message as unknown as Uint8Array<ArrayBuffer>);
|
||||
if (relay.ws && relay.ws.readyState === WebSocket.OPEN) relay.ws.send(frame);
|
||||
else relay.queue.push(frame);
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<PluginWSData>) {
|
||||
@@ -114,7 +133,7 @@ export const pluginWsWebsocket = {
|
||||
relays.delete(ws);
|
||||
if (relay) relay.done = true;
|
||||
try {
|
||||
relay?.socket?.end();
|
||||
relay?.ws?.close();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
@@ -165,12 +184,18 @@ export async function upgradePluginWs(req: Request, server: any): Promise<Respon
|
||||
// recipient lookup.
|
||||
if (!target) return new Response('Not found', { status: 404 });
|
||||
|
||||
// The rest of the query is the plugin's to interpret and is passed through untouched. The CREDENTIAL is
|
||||
// not: a sidecar has no use for the caller's JWT, and forwarding one puts a bearer token into whatever
|
||||
// that process logs. `X-Officer-User` carries the only part it needs.
|
||||
const forwarded = new URLSearchParams(url.search);
|
||||
forwarded.delete('token');
|
||||
const query = forwarded.toString();
|
||||
|
||||
const ok = server.upgrade(req, {
|
||||
data: {
|
||||
provider: 'plugin-ws',
|
||||
plugin,
|
||||
host: target.host,
|
||||
port: target.port,
|
||||
upstream: `ws://${target.host}:${target.port}/ws${query ? `?${query}` : ''}`,
|
||||
userId: user.id,
|
||||
} satisfies PluginWSData,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user