feat(gateway,sdk): compressed direct gateway listener; private fleet gateways

The gateway can also listen on GATEWAY_PUBLIC_PORT (on Fly:
wss://rs-sdk-demo.fly.dev:7443 -> 7781) with permessage-deflate. On live
bot state frames that is ~16% of the uncompressed bytes at ~6 ms CPU per MB,
spent in the gateway process rather than on the game server's tick thread.
The main port is unchanged and uncompressed, so the engine's /gateway relay
and every existing SDK and runner behave exactly as before.

SDK connect() and the lite runner check the direct port (3 s) and use it when
it answers, otherwise they keep wss://.../gateway; a failed connect checks
again. GATEWAY_DIRECT=false opts out. HTTP endpoints keep the relayed origin.

GATEWAY_HOSTNAME lets a fleet run a private loopback gateway (sdk/README.md).
sdk/chat.ts and the MCP server now honor a bot.env GATEWAY_URL, as cli.ts and
runScript already did. The Dockerfile sed that forced 0.0.0.0 is replaced by
that option's default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
max bittkerandClaude Opus 5.5 committed 2026-10-04 00:46:40 -04:00
1 parent cbfd40e6fa
commit ca34325e7a
9 files changed
+224 -19

No files matched your search

+13
View File
@@ -24,6 +24,8 @@ NODE_TICKRATE = "300"
NODE_XPRATE = "25"
WEBSITE_REGISTRATION = "false"
WEB_PORT = "8080"
# Gateway's direct listener (see the 7443 service below).
GATEWAY_PUBLIC_PORT = "7781"
[http_service]
auto_start_machines = true
@@ -42,6 +44,17 @@ protocol = "tcp"
[[services.ports]]
port = 43594
# SDK gateway, direct: wss://rs-sdk-demo.fly.dev:7443. Compressed state frames,
# and the relay work stays off the game server's tick thread. The SDK and lite
# runner prefer it and fall back to wss://.../gateway if it is unreachable.
[[services]]
internal_port = 7781
protocol = "tcp"
[[services.ports]]
port = 7443
handlers = ["tls", "http"]
[[vm]]
memory = "4gb"
size = "performance-2x"
+4 -1
View File
@@ -65,7 +65,10 @@ class BotManager {
username = env.BOT_USERNAME || name;
pwd = env.PASSWORD;
if (env.SERVER) {
// A bot.env GATEWAY_URL (e.g. a private fleet gateway) wins, as in sdk/cli.ts.
if (env.GATEWAY_URL) {
gateway = env.GATEWAY_URL;
} else if (env.SERVER) {
gateway = deriveGatewayUrl(env.SERVER);
}
+26
View File
@@ -144,6 +144,32 @@ await page.goto("https://rs-sdk-demo.fly.dev/bot?bot=mybot123&password=test");
| `actionTimeout` | `30000` | Action timeout in ms |
| `autoReconnect` | `true` | Auto-reconnect on disconnect |
### Direct gateway
For `rs-sdk-demo.fly.dev`, `connect()` and the lite runner first try the
gateway's own port, `wss://rs-sdk-demo.fly.dev:7443`. State frames arrive
compressed (about 6x smaller), and nothing is relayed through the game server.
If that port doesn't answer within 3 seconds they use
`wss://rs-sdk-demo.fly.dev/gateway` as before. Set `GATEWAY_DIRECT=false` to
skip the check.
### Private gateway for a fleet
If your lite runners and the scripts that control them run on one machine, run
the gateway there too. Bot state then stays on that machine and only game
traffic goes to the server.
```bash
cd server/gateway && bun install
GATEWAY_HOSTNAME=127.0.0.1 LOGIN_SERVER=false bun gateway.ts # ws://127.0.0.1:7780
```
Add `GATEWAY_URL=ws://127.0.0.1:7780` to each bot's `bot.env`. The lite runner,
`runScript` scripts, `sdk/cli.ts`, `sdk/chat.ts` and the MCP server all honor
it. With `LOGIN_SERVER=false` the gateway accepts any password, so keep it on
`127.0.0.1`. Bots in a browser tab always use the game server's `/gateway`, and
bots on a private gateway don't appear in the server's public `/status`.
## Two-Layer API
### Plumbing (BotSDK)
+5 -2
View File
@@ -57,7 +57,7 @@ observers get.
}
/** Same bot.env resolution as sdk/cli.ts. */
function tryLoadBotEnv(botName: string): { username: string; password: string; server?: string } | null {
function tryLoadBotEnv(botName: string): { username: string; password: string; server?: string; gatewayUrl?: string } | null {
const envPath = join(process.cwd(), 'bots', botName, 'bot.env');
if (!existsSync(envPath)) return null;
@@ -78,7 +78,8 @@ function tryLoadBotEnv(botName: string): { username: string; password: string; s
return {
username: env.BOT_USERNAME,
password: env.PASSWORD,
server: env.SERVER
server: env.SERVER,
gatewayUrl: env.GATEWAY_URL
};
}
@@ -368,6 +369,8 @@ async function main() {
username = botEnv.username;
password = botEnv.password;
if (botEnv.server && !server) server = botEnv.server;
// Same as sdk/cli.ts: a bot.env GATEWAY_URL (e.g. a private fleet gateway) wins.
if (botEnv.gatewayUrl && !process.env.GATEWAY_URL) process.env.GATEWAY_URL = botEnv.gatewayUrl;
}
if (!server) server = process.env.SERVER || 'rs-sdk-demo.fly.dev';
+46 -2
View File
@@ -81,6 +81,41 @@ export function deriveGatewayUrl(server?: string): string {
return `wss://${server}/gateway`;
}
// Public servers whose gateway also listens on its own TLS port. Connecting
// there skips the game server's /gateway relay (which runs on the game's tick
// thread) and negotiates permessage-deflate, so state frames arrive ~6x
// smaller. Keyed by the relayed URL deriveGatewayUrl produces; HTTP endpoints
// keep using the relayed origin.
const DIRECT_GATEWAYS: Record<string, string> = {
'wss://rs-sdk-demo.fly.dev/gateway': 'wss://rs-sdk-demo.fly.dev:7443',
};
const directGatewayChecks = new Map<string, Promise<string>>();
/**
* The direct gateway WebSocket URL for a relayed one when its port answers,
* otherwise the URL unchanged. Checked once per process, so a network that
* blocks the port keeps working through the relay. GATEWAY_DIRECT=false opts out.
*/
export function preferDirectGateway(url: string, directGateways: Record<string, string> = DIRECT_GATEWAYS): Promise<string> {
const direct = directGateways[url];
if (!direct || (typeof process !== 'undefined' && process.env?.GATEWAY_DIRECT === 'false')) {
return Promise.resolve(url);
}
let check = directGatewayChecks.get(direct);
if (!check) {
check = fetch(direct.replace(/^ws/, 'http') + '/', { signal: AbortSignal.timeout(3000) })
.then(res => (res.ok ? direct : url), () => url);
directGatewayChecks.set(direct, check);
}
return check;
}
/** Drop a cached direct-gateway check so the next connect probes again. */
export function forgetDirectGateway(url: string, directGateways: Record<string, string> = DIRECT_GATEWAYS): void {
const direct = directGateways[url];
if (direct) directGatewayChecks.delete(direct);
}
interface SyncToSDKMessage {
type: 'sdk_connected' | 'sdk_state' | 'sdk_action_result' | 'sdk_error' | 'sdk_screenshot_response' | 'sdk_info';
success?: boolean;
@@ -224,8 +259,12 @@ export class BotSDK {
}
}
this.connectPromise = new Promise((resolve, reject) => {
const url = this.config.gatewayUrl || `ws://${this.config.host}:${this.config.port}`;
const configuredUrl = this.config.gatewayUrl || `ws://${this.config.host}:${this.config.port}`;
this.connectPromise = preferDirectGateway(configuredUrl).then(url => new Promise<void>((resolve, reject) => {
if (this.intentionalDisconnect) {
reject(new Error('Disconnected while connecting'));
return;
}
this.ws = new WebSocket(url);
// One deadline for the whole handshake - socket open AND the
@@ -338,6 +377,11 @@ export class BotSDK {
} catch {}
};
this.ws.addEventListener('message', checkConnected);
})).catch(error => {
// Re-probe on the next attempt: a direct port that stopped answering
// falls back to the relay instead of failing every reconnect.
forgetDirectGateway(configuredUrl);
throw error;
});
return this.connectPromise;
+72
View File
@@ -0,0 +1,72 @@
// preferDirectGateway: SDK WebSockets go straight to a gateway's own port when
// it answers (compressed frames, no relay through the game server's tick
// thread) and stay on the relayed /gateway URL when it doesn't.
import { afterEach, describe, expect, test } from 'bun:test';
import type { Server } from 'bun';
import { deriveGatewayUrl, forgetDirectGateway, preferDirectGateway } from '../index';
let server: Server | null = null;
afterEach(() => {
server?.stop(true);
server = null;
delete process.env.GATEWAY_DIRECT;
});
describe('preferDirectGateway', () => {
test('uses the direct port when it answers', async () => {
server = Bun.serve({ port: 0, fetch: () => new Response('Gateway Service') });
const direct = `ws://localhost:${server.port}`;
const relayed = 'wss://example.test/gateway';
expect(await preferDirectGateway(relayed, { [relayed]: direct })).toBe(direct);
});
test('keeps the relayed URL when the direct port is unreachable', async () => {
// Bind then release a port so nothing is listening on it.
const probe = Bun.serve({ port: 0, fetch: () => new Response('') });
const closedPort = probe.port;
probe.stop(true);
const relayed = 'wss://unreachable.test/gateway';
expect(await preferDirectGateway(relayed, { [relayed]: `ws://localhost:${closedPort}` })).toBe(relayed);
});
test('keeps the relayed URL when the direct port answers with an error', async () => {
server = Bun.serve({ port: 0, fetch: () => new Response('nope', { status: 502 }) });
const relayed = 'wss://erroring.test/gateway';
expect(await preferDirectGateway(relayed, { [relayed]: `ws://localhost:${server.port}` })).toBe(relayed);
});
test('GATEWAY_DIRECT=false opts out', async () => {
server = Bun.serve({ port: 0, fetch: () => new Response('Gateway Service') });
process.env.GATEWAY_DIRECT = 'false';
const relayed = 'wss://optout.test/gateway';
expect(await preferDirectGateway(relayed, { [relayed]: `ws://localhost:${server.port}` })).toBe(relayed);
});
test('leaves URLs without a direct gateway untouched', async () => {
expect(await preferDirectGateway('ws://localhost:7780')).toBe('ws://localhost:7780');
expect(await preferDirectGateway('wss://other-server.test/gateway')).toBe('wss://other-server.test/gateway');
});
test('the public demo server has a direct gateway, keyed by its derived URL', () => {
// HTTP helpers keep deriving from the relayed origin; only the socket moves.
expect(deriveGatewayUrl('rs-sdk-demo.fly.dev')).toBe('wss://rs-sdk-demo.fly.dev/gateway');
});
});
describe('forgetDirectGateway', () => {
test('a forgotten check probes again and falls back once the port is gone', async () => {
server = Bun.serve({ port: 0, fetch: () => new Response('Gateway Service') });
const relayed = 'wss://restarting.test/gateway';
const gateways = { [relayed]: `ws://localhost:${server.port}` };
expect(await preferDirectGateway(relayed, gateways)).toBe(gateways[relayed]);
server.stop(true);
server = null;
// Cached: still answers direct until a failed connect forgets it.
expect(await preferDirectGateway(relayed, gateways)).toBe(gateways[relayed]);
forgetDirectGateway(relayed, gateways);
expect(await preferDirectGateway(relayed, gateways)).toBe(relayed);
});
});
-4
View File
@@ -56,10 +56,6 @@ WORKDIR /opt/server/engine
RUN sed -i 's/port: Environment.WEB_PORT,/port: Environment.WEB_PORT, hostname: "0.0.0.0",/' src/web/index.ts && \
BUILD_VERIFY=false bun run build
# Patch gateway
WORKDIR /opt/server/gateway
RUN sed -i 's/port: GATEWAY_PORT,/port: GATEWAY_PORT, hostname: "0.0.0.0",/' gateway.ts
WORKDIR /opt/server
EXPOSE 8080/tcp
+28 -7
View File
@@ -15,6 +15,16 @@ import { ChatHistory } from '../../sdk/chat-history';
import { chunkMessage } from '../../sdk/chunking';
const GATEWAY_PORT = parseInt(process.env.AGENT_PORT || '7780');
// Bind address. Fleet hosts running a private gateway set 127.0.0.1 so only
// local processes can reach it (it has no auth when LOGIN_SERVER is off).
const GATEWAY_HOSTNAME = process.env.GATEWAY_HOSTNAME || '0.0.0.0';
// Optional second listener that clients reach directly (on Fly: wss://host:7443
// -> 7781), instead of through the game server's /gateway relay. It negotiates
// permessage-deflate: live state frames compress to ~13% at ~6 ms CPU per MB,
// spent in this process rather than on the game's tick thread. The main port
// stays uncompressed - the engine relay reaches it over loopback, where
// compressing would only cost CPU on both ends.
const GATEWAY_PUBLIC_PORT = parseInt(process.env.GATEWAY_PUBLIC_PORT || '0');
// Login server configuration - when enabled, SDK connections require per-bot authentication
const LOGIN_SERVER_ENABLED = process.env.LOGIN_SERVER === 'true';
@@ -226,7 +236,8 @@ const SyncModule = {
sendToBot(session: BotSession, message: SyncToBotMessage) {
if (session.ws) {
try {
session.ws.send(JSON.stringify(message));
// compress only takes effect on sockets that negotiated deflate (public listener)
session.ws.send(JSON.stringify(message), true);
} catch (error) {
console.error(`[Gateway] [${session.username}] Failed to send to bot:`, error);
}
@@ -236,7 +247,7 @@ const SyncModule = {
sendToSDK(session: SDKSession, message: SyncToSDKMessage) {
if (session.ws) {
try {
session.ws.send(JSON.stringify(message));
session.ws.send(JSON.stringify(message), true);
} catch (error) {
console.error(`[Gateway] [${session.sdkClientId}] Failed to send to SDK:`, error);
}
@@ -779,10 +790,8 @@ process.on('uncaughtException', (error) => {
console.log(`[Gateway] Starting Gateway Service on port ${GATEWAY_PORT}...`);
const server = Bun.serve({
port: GATEWAY_PORT,
async fetch(req, server) {
const gatewayHandlers = {
async fetch(req: Request, server: any) {
const url = new URL(req.url);
// WebSocket upgrade
@@ -1087,7 +1096,19 @@ Bots: ${botSessions.size} | SDKs: ${sdkSessions.size}
}
}
}
});
};
Bun.serve({ port: GATEWAY_PORT, hostname: GATEWAY_HOSTNAME, ...gatewayHandlers });
if (GATEWAY_PUBLIC_PORT) {
Bun.serve({
port: GATEWAY_PUBLIC_PORT,
hostname: GATEWAY_HOSTNAME,
fetch: gatewayHandlers.fetch,
websocket: { ...gatewayHandlers.websocket, perMessageDeflate: true }
});
console.log(`[Gateway] Public listener (permessage-deflate) on port ${GATEWAY_PUBLIC_PORT}`);
}
console.log(`[Gateway] Gateway running at http://localhost:${GATEWAY_PORT}`);
console.log(`[Gateway] Bot/SDK: ws://localhost:${GATEWAY_PORT}`);
+30 -3
View File
@@ -47,6 +47,7 @@ class LiteGatewayRunner {
private wsConnected = false;
private preventReconnect = false;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private resolvingGateway = false;
private tickTimer: ReturnType<typeof setInterval> | null = null;
constructor(
@@ -83,10 +84,19 @@ class LiteGatewayRunner {
}
connect(): void {
if (this.ws) return;
if (this.ws || this.resolvingGateway) return;
// Re-checked on every (re)connect, so the direct port is picked up again
// after a server restart and abandoned if it ever stops answering.
this.resolvingGateway = true;
void preferDirectGateway(this.gatewayUrl).then(url => {
this.resolvingGateway = false;
if (!this.ws && !this.preventReconnect) this.open(url);
});
}
console.log(`[lite-runner] Connecting to gateway ${this.gatewayUrl}`);
this.ws = new WebSocket(this.gatewayUrl);
private open(url: string): void {
console.log(`[lite-runner] Connecting to gateway ${url}`);
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this.wsConnected = true;
@@ -351,6 +361,23 @@ function deriveGatewayUrl(server: string): string {
return `wss://${server}/gateway`;
}
// Same table as sdk/index.ts preferDirectGateway: publish straight to the
// gateway's own TLS port when it answers (compressed frames, no relay through
// the game server's tick thread), else keep the relayed /gateway URL.
const DIRECT_GATEWAYS: Record<string, string> = {
'wss://rs-sdk-demo.fly.dev/gateway': 'wss://rs-sdk-demo.fly.dev:7443',
};
async function preferDirectGateway(url: string): Promise<string> {
const direct = DIRECT_GATEWAYS[url];
if (!direct || process.env.GATEWAY_DIRECT === 'false') return url;
try {
const res = await fetch(direct.replace(/^ws/, 'http') + '/', { signal: AbortSignal.timeout(3000) });
return res.ok ? direct : url;
} catch {
return url;
}
}
const botName = process.argv[2];
if (!botName) {
console.error('Usage: bun src/lite/runner.ts <botname>');