mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
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:
1 parent
cbfd40e6fa
commit
ca34325e7a
9 files changed
+224
-19
No files matched your search
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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>');
|
||||
|
||||
Reference in new issue
Block a user