@serverkgg/bridge 0.0.13 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,275 @@
1
+ import { BridgeRconCode, BridgeRconError } from "../core";
2
+ import { rconTarget } from "./rconError";
3
+ import { encodeRconFrame, RconRequest, RconResponse } from "./rconFrame";
4
+ import { createRconIds, type RconIds } from "./rconIds";
5
+ import { openRconLink, type RconLink } from "./rconLink";
6
+
7
+ const DEFAULT_HOST = "127.0.0.1";
8
+
9
+ const DEFAULT_TIMEOUT_MS = 10_000;
10
+
11
+ export interface RconClientOptions {
12
+ host?: string;
13
+ port: number;
14
+ password: string;
15
+ timeoutMs?: number;
16
+ multiPacket?: boolean;
17
+ mirroredIds?: boolean;
18
+ idSeed?: number;
19
+ }
20
+
21
+ export interface RconCommandOptions {
22
+ timeoutMs?: number;
23
+ }
24
+
25
+ interface RconClientFrames {
26
+ request: Uint8Array;
27
+ probe: Uint8Array | null;
28
+ }
29
+
30
+ export class RconClient {
31
+ readonly #host: string;
32
+ readonly #port: number;
33
+ readonly #password: string;
34
+ readonly #timeoutMs: number;
35
+ readonly #multiPacket: boolean;
36
+ readonly #mirroredIds: boolean;
37
+ readonly #target: string;
38
+ readonly #ids: RconIds;
39
+
40
+ #link: RconLink | null = null;
41
+ #dialing: Promise<RconLink> | null = null;
42
+ #locked: BridgeRconError | null = null;
43
+ #epoch = 0;
44
+ #queue: Promise<unknown> = Promise.resolve();
45
+
46
+ constructor(options: RconClientOptions) {
47
+ this.#host = options.host ?? DEFAULT_HOST;
48
+ this.#port = options.port;
49
+ this.#password = options.password;
50
+ this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
51
+ this.#multiPacket = options.multiPacket !== false;
52
+ this.#mirroredIds = options.mirroredIds !== false;
53
+ this.#target = rconTarget(this.#host, this.#port);
54
+ this.#ids = createRconIds(options.idSeed);
55
+ }
56
+
57
+ get connected(): boolean {
58
+ return this.#link?.alive ?? false;
59
+ }
60
+
61
+ async connect(): Promise<void> {
62
+ this.#locked = null;
63
+
64
+ await this.#ensureLink();
65
+ }
66
+
67
+ command(text: string, options: RconCommandOptions = {}): Promise<string> {
68
+ const timeoutMs = options.timeoutMs ?? this.#timeoutMs;
69
+ const deadlineAt = performance.now() + timeoutMs;
70
+ const epoch = this.#epoch;
71
+
72
+ return new Promise<string>((resolve, reject) => {
73
+ let settled = false;
74
+
75
+ const timer = setTimeout(() => {
76
+ settled = true;
77
+
78
+ reject(this.#timedOut());
79
+ }, timeoutMs);
80
+
81
+ void this.#serialize(async () => {
82
+ if (settled) {
83
+ return;
84
+ }
85
+
86
+ try {
87
+ const reply = await this.#run(text, deadlineAt, epoch);
88
+
89
+ if (settled) {
90
+ return;
91
+ }
92
+
93
+ settled = true;
94
+
95
+ clearTimeout(timer);
96
+ resolve(reply);
97
+ } catch (error) {
98
+ if (settled) {
99
+ return;
100
+ }
101
+
102
+ settled = true;
103
+
104
+ clearTimeout(timer);
105
+ reject(error);
106
+ }
107
+ });
108
+ });
109
+ }
110
+
111
+ async disconnect(): Promise<void> {
112
+ this.#epoch += 1;
113
+ this.#dialing = null;
114
+
115
+ const link = this.#link;
116
+
117
+ this.#link = null;
118
+
119
+ if (!link) {
120
+ return;
121
+ }
122
+
123
+ await link.close(this.#closed());
124
+ }
125
+
126
+ #closed(): BridgeRconError {
127
+ return new BridgeRconError(BridgeRconCode.Closed, `the rcon connection to ${this.#target} was closed`);
128
+ }
129
+
130
+ #timedOut(): BridgeRconError {
131
+ return new BridgeRconError(BridgeRconCode.TimedOut, `${this.#target} did not answer the rcon request in time`);
132
+ }
133
+
134
+ #serialize<Result>(work: () => Promise<Result>): Promise<Result> {
135
+ const next = this.#queue.then(work, work);
136
+
137
+ this.#queue = next.then(
138
+ () => undefined,
139
+ () => undefined,
140
+ );
141
+
142
+ return next;
143
+ }
144
+
145
+ #frames(text: string, id: number, probeId: number | null): RconClientFrames {
146
+ const request = encodeRconFrame({
147
+ id,
148
+ type: RconRequest.Command,
149
+ body: text,
150
+ });
151
+
152
+ if (probeId === null) {
153
+ return {
154
+ request,
155
+ probe: null,
156
+ };
157
+ }
158
+
159
+ return {
160
+ request,
161
+ probe: encodeRconFrame({
162
+ id: probeId,
163
+ type: RconResponse.Value,
164
+ body: "",
165
+ }),
166
+ };
167
+ }
168
+
169
+ async #run(text: string, deadlineAt: number, epoch: number): Promise<string> {
170
+ for (let attempt = 0; ; attempt += 1) {
171
+ if (epoch !== this.#epoch) {
172
+ throw this.#closed();
173
+ }
174
+
175
+ if (this.#locked) {
176
+ throw this.#locked;
177
+ }
178
+
179
+ const pair = this.#ids.next();
180
+ const probeId = this.#multiPacket && this.#mirroredIds ? pair.probeId : null;
181
+ const frames = this.#frames(text, pair.id, probeId);
182
+ const link = await this.#ensureLink();
183
+
184
+ if (epoch !== this.#epoch) {
185
+ throw this.#closed();
186
+ }
187
+
188
+ if (performance.now() >= deadlineAt) {
189
+ throw this.#timedOut();
190
+ }
191
+
192
+ const sent = link.send({
193
+ id: pair.id,
194
+ probeId,
195
+ request: frames.request,
196
+ probe: frames.probe,
197
+ mirroredIds: this.#mirroredIds,
198
+ deadlineAt,
199
+ });
200
+
201
+ if (sent.written === 0 && !link.alive && attempt === 0) {
202
+ await sent.reply.catch(() => undefined);
203
+
204
+ continue;
205
+ }
206
+
207
+ return await sent.reply;
208
+ }
209
+ }
210
+
211
+ async #ensureLink(): Promise<RconLink> {
212
+ if (this.#locked) {
213
+ throw this.#locked;
214
+ }
215
+
216
+ const current = this.#link;
217
+
218
+ if (current?.alive) {
219
+ return current;
220
+ }
221
+
222
+ if (!this.#dialing) {
223
+ const dial = this.#dial();
224
+
225
+ this.#dialing = dial;
226
+
227
+ void dial
228
+ .catch(() => undefined)
229
+ .finally(() => {
230
+ if (this.#dialing === dial) {
231
+ this.#dialing = null;
232
+ }
233
+ });
234
+ }
235
+
236
+ return await this.#dialing;
237
+ }
238
+
239
+ async #dial(): Promise<RconLink> {
240
+ const epoch = this.#epoch;
241
+
242
+ try {
243
+ const link = await openRconLink({
244
+ host: this.#host,
245
+ port: this.#port,
246
+ password: this.#password,
247
+ timeoutMs: this.#timeoutMs,
248
+ ids: this.#ids,
249
+ onLost: () => {
250
+ if (this.#link === link) {
251
+ this.#link = null;
252
+ }
253
+ },
254
+ });
255
+
256
+ if (epoch !== this.#epoch) {
257
+ const closed = this.#closed();
258
+
259
+ await link.close(closed);
260
+
261
+ throw closed;
262
+ }
263
+
264
+ this.#link = link;
265
+
266
+ return link;
267
+ } catch (error) {
268
+ if (error instanceof BridgeRconError && error.code === BridgeRconCode.AuthFailed) {
269
+ this.#locked = error;
270
+ }
271
+
272
+ throw error;
273
+ }
274
+ }
275
+ }
@@ -0,0 +1,21 @@
1
+ import { BridgeRconCode, BridgeRconError } from "../core";
2
+
3
+ const DETAILED_CODES = new Set([
4
+ BridgeRconCode.Malformed,
5
+ BridgeRconCode.Unreachable,
6
+ ]);
7
+
8
+ export const rconTarget = (host: string, port: number) => {
9
+ return `${host}:${port}`;
10
+ };
11
+
12
+ export const rconError = (code: BridgeRconCode, message: string, cause?: unknown) => {
13
+ if (cause instanceof BridgeRconError) {
14
+ return cause;
15
+ }
16
+
17
+ const detail =
18
+ DETAILED_CODES.has(code) && cause instanceof Error && cause.message.length > 0 ? ` — ${cause.message}` : "";
19
+
20
+ return new BridgeRconError(code, `${message}${detail}`);
21
+ };
@@ -0,0 +1,269 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { BridgeRconCode, BridgeRconError } from "../core";
3
+ import {
4
+ concatBytes,
5
+ decodeRconFrames,
6
+ encodeRconFrame,
7
+ RCON_BODY_LIMIT,
8
+ RCON_REPLY_LIMIT,
9
+ RconRequest,
10
+ RconResponse,
11
+ } from "./rconFrame";
12
+
13
+ const HEADER_LENGTH = 4;
14
+
15
+ const NUL = String.fromCharCode(0);
16
+
17
+ const TRAILER_BODY = `${NUL}${String.fromCharCode(1)}${NUL}${NUL}`;
18
+
19
+ const thrown = (run: () => unknown) => {
20
+ try {
21
+ run();
22
+
23
+ return null;
24
+ } catch (reason: unknown) {
25
+ return reason;
26
+ }
27
+ };
28
+
29
+ const declaredFrame = (size: number) => {
30
+ const packet = new Uint8Array(HEADER_LENGTH + Math.max(size, 0));
31
+
32
+ new DataView(packet.buffer).setInt32(0, size, true);
33
+
34
+ return packet;
35
+ };
36
+
37
+ describe("encodeRconFrame", () => {
38
+ test("writes size, id and type as little endian int32 followed by two nulls", () => {
39
+ const packet = encodeRconFrame({
40
+ id: 7,
41
+ type: RconRequest.Auth,
42
+ body: "pass",
43
+ });
44
+
45
+ const view = new DataView(packet.buffer);
46
+
47
+ expect(packet).toHaveLength(18);
48
+ expect(view.getInt32(0, true)).toBe(14);
49
+ expect(view.getInt32(4, true)).toBe(7);
50
+ expect(view.getInt32(8, true)).toBe(3);
51
+ expect(new TextDecoder().decode(packet.subarray(12, 16))).toBe("pass");
52
+ expect(packet.at(16)).toBe(0);
53
+ expect(packet.at(17)).toBe(0);
54
+ });
55
+
56
+ test("refuses a body above the 4096 byte cap", () => {
57
+ const error = thrown(() =>
58
+ encodeRconFrame({
59
+ id: 1,
60
+ type: RconRequest.Command,
61
+ body: "x".repeat(RCON_BODY_LIMIT + 1),
62
+ }),
63
+ );
64
+
65
+ expect(error).toBeInstanceOf(BridgeRconError);
66
+ expect((error as BridgeRconError).code).toBe(BridgeRconCode.Oversized);
67
+ });
68
+ });
69
+
70
+ describe("decodeRconFrames", () => {
71
+ test("round-trips a frame", () => {
72
+ const decoded = decodeRconFrames(
73
+ encodeRconFrame({
74
+ id: 3,
75
+ type: RconResponse.Value,
76
+ body: "hello",
77
+ }),
78
+ );
79
+
80
+ expect(decoded.frames).toEqual([
81
+ {
82
+ id: 3,
83
+ type: 0,
84
+ body: "hello",
85
+ bodyBytes: 5,
86
+ },
87
+ ]);
88
+ expect(decoded.rest).toHaveLength(0);
89
+ });
90
+
91
+ test("reads several frames out of one buffer", () => {
92
+ const decoded = decodeRconFrames(
93
+ concatBytes(
94
+ encodeRconFrame({
95
+ id: 1,
96
+ type: RconResponse.Value,
97
+ body: "a",
98
+ }),
99
+ encodeRconFrame({
100
+ id: 2,
101
+ type: RconResponse.Auth,
102
+ body: "",
103
+ }),
104
+ ),
105
+ );
106
+
107
+ expect(decoded.frames.map((frame) => frame.id)).toEqual([
108
+ 1,
109
+ 2,
110
+ ]);
111
+ });
112
+
113
+ test("keeps a partial frame for the next chunk", () => {
114
+ const packet = encodeRconFrame({
115
+ id: 9,
116
+ type: RconResponse.Value,
117
+ body: "split",
118
+ });
119
+
120
+ const first = decodeRconFrames(packet.subarray(0, 8));
121
+
122
+ expect(first.frames).toEqual([]);
123
+ expect(first.rest).toHaveLength(8);
124
+
125
+ const second = decodeRconFrames(concatBytes(first.rest, packet.subarray(8)));
126
+
127
+ expect(second.frames).toEqual([
128
+ {
129
+ id: 9,
130
+ type: 0,
131
+ body: "split",
132
+ bodyBytes: 5,
133
+ },
134
+ ]);
135
+ });
136
+
137
+ test("keeps a frame split inside the size prefix", () => {
138
+ const packet = encodeRconFrame({
139
+ id: 11,
140
+ type: RconResponse.Value,
141
+ body: "prefix",
142
+ });
143
+
144
+ const first = decodeRconFrames(packet.subarray(0, 2));
145
+
146
+ expect(first.frames).toEqual([]);
147
+ expect(first.rest).toHaveLength(2);
148
+
149
+ const second = decodeRconFrames(concatBytes(first.rest, packet.subarray(2)));
150
+
151
+ expect(second.frames.at(0)?.body).toBe("prefix");
152
+ });
153
+
154
+ test("reads an empty body", () => {
155
+ expect(
156
+ decodeRconFrames(
157
+ encodeRconFrame({
158
+ id: -1,
159
+ type: RconResponse.Auth,
160
+ body: "",
161
+ }),
162
+ ).frames,
163
+ ).toEqual([
164
+ {
165
+ id: -1,
166
+ type: 2,
167
+ body: "",
168
+ bodyBytes: 0,
169
+ },
170
+ ]);
171
+ });
172
+
173
+ test("round-trips a multi-byte utf-8 body and counts its bytes", () => {
174
+ const body = "اللاعب انضم — 참가자";
175
+
176
+ const frame = decodeRconFrames(
177
+ encodeRconFrame({
178
+ id: 4,
179
+ type: RconResponse.Value,
180
+ body,
181
+ }),
182
+ ).frames.at(0);
183
+
184
+ expect(frame?.body).toBe(body);
185
+ expect(frame?.bodyBytes).toBe(new TextEncoder().encode(body).length);
186
+ });
187
+
188
+ test("keeps a body with an embedded null", () => {
189
+ const body = `left${NUL}right`;
190
+
191
+ expect(
192
+ decodeRconFrames(
193
+ encodeRconFrame({
194
+ id: 5,
195
+ type: RconResponse.Value,
196
+ body,
197
+ }),
198
+ ).frames.at(0)?.body,
199
+ ).toBe(body);
200
+ });
201
+
202
+ test("reads the srcds trailer as an ordinary frame", () => {
203
+ expect(
204
+ decodeRconFrames(
205
+ encodeRconFrame({
206
+ id: 6,
207
+ type: RconResponse.Value,
208
+ body: TRAILER_BODY,
209
+ }),
210
+ ).frames.at(0),
211
+ ).toEqual({
212
+ id: 6,
213
+ type: 0,
214
+ body: TRAILER_BODY,
215
+ bodyBytes: 4,
216
+ });
217
+ });
218
+
219
+ test("rejects a packet declaring an impossible size", () => {
220
+ const error = thrown(() => decodeRconFrames(declaredFrame(2)));
221
+
222
+ expect(error).toBeInstanceOf(BridgeRconError);
223
+ expect((error as BridgeRconError).code).toBe(BridgeRconCode.Malformed);
224
+ });
225
+
226
+ test("reads a frame of exactly the reply limit and rejects one byte more", () => {
227
+ const decoded = decodeRconFrames(declaredFrame(RCON_REPLY_LIMIT));
228
+
229
+ expect(decoded.frames).toHaveLength(1);
230
+ expect(decoded.frames.at(0)?.body).toHaveLength(RCON_REPLY_LIMIT - 10);
231
+
232
+ const error = thrown(() => decodeRconFrames(declaredFrame(RCON_REPLY_LIMIT + 1)));
233
+
234
+ expect(error).toBeInstanceOf(BridgeRconError);
235
+ expect((error as BridgeRconError).code).toBe(BridgeRconCode.Malformed);
236
+ });
237
+ });
238
+
239
+ describe("concatBytes", () => {
240
+ test("copies a non-empty left side and keeps the order", () => {
241
+ const left = new Uint8Array([
242
+ 1,
243
+ 2,
244
+ ]);
245
+
246
+ const right = new Uint8Array([
247
+ 3,
248
+ 4,
249
+ ]);
250
+
251
+ const joined = concatBytes(left, right);
252
+
253
+ left.set([
254
+ 9,
255
+ 9,
256
+ ]);
257
+ right.set([
258
+ 9,
259
+ 9,
260
+ ]);
261
+
262
+ expect(Array.from(joined)).toEqual([
263
+ 1,
264
+ 2,
265
+ 3,
266
+ 4,
267
+ ]);
268
+ });
269
+ });
@@ -1,42 +1,48 @@
1
1
  import { BridgeRconCode, BridgeRconError } from "../core";
2
2
 
3
- export enum SourceRconRequest {
3
+ export enum RconRequest {
4
4
  Command = 2,
5
5
  Auth = 3,
6
6
  }
7
7
 
8
- export enum SourceRconResponse {
8
+ export enum RconResponse {
9
9
  Value = 0,
10
10
  Auth = 2,
11
11
  }
12
12
 
13
- export const SOURCE_RCON_BODY_LIMIT = 4096;
13
+ export const RCON_BODY_LIMIT = 4096;
14
14
 
15
- export const SOURCE_RCON_AUTH_FAILED_ID = -1;
15
+ export const RCON_REPLY_LIMIT = 1024 * 1024;
16
+
17
+ export const RCON_SPLIT_THRESHOLD = 4000;
18
+
19
+ export const RCON_AUTH_FAILED_ID = -1;
16
20
 
17
21
  const HEADER_LENGTH = 4;
18
22
 
19
23
  const MIN_FRAME_SIZE = 10;
20
24
 
21
- const MAX_FRAME_SIZE = 65_536;
22
-
23
25
  const ENCODER = new TextEncoder();
24
26
 
25
27
  const DECODER = new TextDecoder();
26
28
 
27
- export interface SourceRconFrame {
29
+ export interface RconFrame {
28
30
  id: number;
29
31
  type: number;
30
32
  body: string;
31
33
  }
32
34
 
33
- export const encodeSourceRconFrame = (frame: SourceRconFrame) => {
35
+ export interface RconDecodedFrame extends RconFrame {
36
+ bodyBytes: number;
37
+ }
38
+
39
+ export const encodeRconFrame = (frame: RconFrame) => {
34
40
  const body = ENCODER.encode(frame.body);
35
41
 
36
- if (body.length > SOURCE_RCON_BODY_LIMIT) {
42
+ if (body.length > RCON_BODY_LIMIT) {
37
43
  throw new BridgeRconError(
38
44
  BridgeRconCode.Oversized,
39
- `an rcon body of ${body.length} bytes exceeds the ${SOURCE_RCON_BODY_LIMIT} byte limit`,
45
+ `an rcon body of ${body.length} bytes exceeds the ${RCON_BODY_LIMIT} byte limit`,
40
46
  );
41
47
  }
42
48
 
@@ -52,15 +58,15 @@ export const encodeSourceRconFrame = (frame: SourceRconFrame) => {
52
58
  return packet;
53
59
  };
54
60
 
55
- export const decodeSourceRconFrames = (buffer: Uint8Array) => {
56
- const frames: SourceRconFrame[] = [];
61
+ export const decodeRconFrames = (buffer: Uint8Array) => {
62
+ const frames: RconDecodedFrame[] = [];
57
63
 
58
64
  let offset = 0;
59
65
 
60
66
  while (buffer.length - offset >= HEADER_LENGTH) {
61
67
  const size = new DataView(buffer.buffer, buffer.byteOffset + offset, HEADER_LENGTH).getInt32(0, true);
62
68
 
63
- if (size < MIN_FRAME_SIZE || size > MAX_FRAME_SIZE) {
69
+ if (size < MIN_FRAME_SIZE || size > RCON_REPLY_LIMIT) {
64
70
  throw new BridgeRconError(BridgeRconCode.Malformed, `an rcon packet declares an impossible size of ${size}`);
65
71
  }
66
72
 
@@ -69,11 +75,14 @@ export const decodeSourceRconFrames = (buffer: Uint8Array) => {
69
75
  }
70
76
 
71
77
  const head = new DataView(buffer.buffer, buffer.byteOffset + offset + HEADER_LENGTH, 8);
78
+ const bodyStart = offset + 12;
79
+ const bodyEnd = offset + HEADER_LENGTH + size - 2;
72
80
 
73
81
  frames.push({
74
82
  id: head.getInt32(0, true),
75
83
  type: head.getInt32(4, true),
76
- body: DECODER.decode(buffer.subarray(offset + 12, offset + HEADER_LENGTH + size - 2)),
84
+ body: DECODER.decode(buffer.subarray(bodyStart, bodyEnd)),
85
+ bodyBytes: bodyEnd - bodyStart,
77
86
  });
78
87
 
79
88
  offset += HEADER_LENGTH + size;
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createRconIds } from "./rconIds";
3
+
4
+ const LAST_ID = 2_147_483_646;
5
+
6
+ describe("createRconIds", () => {
7
+ test("hands out one and two first", () => {
8
+ expect(createRconIds().next()).toEqual({
9
+ id: 1,
10
+ probeId: 2,
11
+ });
12
+ });
13
+
14
+ test("hands out strictly increasing pairs", () => {
15
+ const ids = createRconIds();
16
+
17
+ let previous = ids.next();
18
+
19
+ for (let index = 0; index < 100; index += 1) {
20
+ const pair = ids.next();
21
+
22
+ expect(pair.id).toBeGreaterThan(previous.probeId);
23
+ expect(pair.probeId).toBeGreaterThan(pair.id);
24
+
25
+ previous = pair;
26
+ }
27
+ });
28
+
29
+ test("wraps back to one without ever handing out zero or minus one", () => {
30
+ const ids = createRconIds(LAST_ID - 3);
31
+
32
+ const pairs = [
33
+ ids.next(),
34
+ ids.next(),
35
+ ids.next(),
36
+ ];
37
+
38
+ expect(pairs).toEqual([
39
+ {
40
+ id: LAST_ID - 3,
41
+ probeId: LAST_ID - 2,
42
+ },
43
+ {
44
+ id: LAST_ID - 1,
45
+ probeId: LAST_ID,
46
+ },
47
+ {
48
+ id: 1,
49
+ probeId: 2,
50
+ },
51
+ ]);
52
+
53
+ for (const pair of pairs) {
54
+ expect(pair.id).toBeGreaterThan(0);
55
+ expect(pair.probeId).toBeGreaterThan(pair.id);
56
+ expect(pair.probeId).toBeLessThanOrEqual(LAST_ID);
57
+ }
58
+ });
59
+ });