@serverkgg/bridge 0.0.12 → 0.0.13

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.
Files changed (70) hide show
  1. package/package.json +9 -1
  2. package/src/catalogs/curseforge.test.ts +580 -0
  3. package/src/catalogs/curseforge.ts +461 -0
  4. package/src/catalogs/index.ts +2 -0
  5. package/src/catalogs/thunderstore.test.ts +94 -0
  6. package/src/catalogs/thunderstore.ts +239 -0
  7. package/src/codecs/index.ts +3 -0
  8. package/src/codecs/ini.test.ts +152 -0
  9. package/src/codecs/ini.ts +158 -0
  10. package/src/codecs/sourceCfg.test.ts +100 -0
  11. package/src/codecs/sourceCfg.ts +111 -0
  12. package/src/codecs/xmlProperties.test.ts +104 -0
  13. package/src/codecs/xmlProperties.ts +133 -0
  14. package/src/context/bridgeContext.ts +20 -0
  15. package/src/core/bridgeRconError.ts +19 -0
  16. package/src/core/index.ts +1 -0
  17. package/src/install/index.ts +1 -0
  18. package/src/install/installStamp.test.ts +125 -0
  19. package/src/install/installStamp.ts +40 -0
  20. package/src/main.ts +1 -0
  21. package/src/manifest/index.ts +1 -0
  22. package/src/manifest/manifest.test.ts +427 -2
  23. package/src/manifest/manifest.ts +141 -13
  24. package/src/manifest/primitives.ts +6 -0
  25. package/src/manifest/runtime.ts +89 -0
  26. package/src/net/index.ts +2 -0
  27. package/src/net/loopback.test.ts +152 -0
  28. package/src/net/loopback.ts +109 -0
  29. package/src/net/loopbackMethod.ts +7 -0
  30. package/src/presence/index.ts +1 -0
  31. package/src/presence/rosterSync.test.ts +179 -0
  32. package/src/presence/rosterSync.ts +57 -0
  33. package/src/protocol/bridgeEvents.ts +4 -0
  34. package/src/rcon/index.ts +3 -0
  35. package/src/rcon/sourceRcon.test.ts +308 -0
  36. package/src/rcon/sourceRcon.ts +276 -0
  37. package/src/rcon/sourceRconFrame.test.ts +152 -0
  38. package/src/rcon/sourceRconFrame.ts +99 -0
  39. package/src/runtime/bridgeCodec.ts +45 -2
  40. package/src/runtime/bridgeContext.ts +13 -6
  41. package/src/runtime/bridgeDriver.test.ts +1 -0
  42. package/src/runtime/bridgeLiveConfig.test.ts +149 -0
  43. package/src/runtime/bridgeLiveConfig.ts +63 -0
  44. package/src/runtime/bridgeRcon.ts +15 -0
  45. package/src/runtime/bridgeRegistry.ts +1 -0
  46. package/src/steam/index.ts +7 -0
  47. package/src/steam/steamApp.test.ts +78 -0
  48. package/src/steam/steamApp.ts +35 -0
  49. package/src/steam/steamcmd.test.ts +157 -0
  50. package/src/steam/steamcmd.ts +273 -0
  51. package/src/steam/steamcmdLogin.ts +34 -0
  52. package/src/steam/steamcmdNarrator.test.ts +90 -0
  53. package/src/steam/steamcmdNarrator.ts +43 -0
  54. package/src/steam/steamcmdPlatform.ts +4 -0
  55. package/src/steam/webApi.ts +70 -0
  56. package/src/steam/workshop.test.ts +45 -0
  57. package/src/steam/workshop.ts +148 -0
  58. package/src/utils/execDetail.ts +16 -0
  59. package/src/utils/index.ts +2 -0
  60. package/src/utils/token.test.ts +50 -0
  61. package/src/utils/token.ts +35 -0
  62. package/src/wine/index.ts +3 -0
  63. package/src/wine/protonCommand.test.ts +67 -0
  64. package/src/wine/protonCommand.ts +49 -0
  65. package/src/wine/wineCommand.test.ts +92 -0
  66. package/src/wine/wineCommand.ts +54 -0
  67. package/src/wine/winePrefix.test.ts +111 -0
  68. package/src/wine/winePrefix.ts +88 -0
  69. package/src/runtime/bridgeSecrets.test.ts +0 -121
  70. package/src/runtime/bridgeSecrets.ts +0 -43
@@ -0,0 +1,308 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { BridgeRconCode, BridgeRconError } from "../core";
3
+ import { sourceRcon } from "./sourceRcon";
4
+ import {
5
+ concatBytes,
6
+ decodeSourceRconFrames,
7
+ encodeSourceRconFrame,
8
+ SOURCE_RCON_AUTH_FAILED_ID,
9
+ type SourceRconFrame,
10
+ SourceRconRequest,
11
+ SourceRconResponse,
12
+ } from "./sourceRconFrame";
13
+
14
+ type Reply = (frame: SourceRconFrame) => void;
15
+
16
+ const HOST = "127.0.0.1";
17
+
18
+ const rconServer = (handle: (frame: SourceRconFrame, reply: Reply) => void) => {
19
+ const pending = new WeakMap<object, Uint8Array>();
20
+
21
+ return Bun.listen({
22
+ hostname: HOST,
23
+ port: 0,
24
+ socket: {
25
+ data(socket, chunk) {
26
+ const decoded = decodeSourceRconFrames(concatBytes(pending.get(socket) ?? new Uint8Array(0), chunk));
27
+
28
+ pending.set(socket, decoded.rest);
29
+
30
+ for (const frame of decoded.frames) {
31
+ handle(frame, (out) => {
32
+ socket.write(encodeSourceRconFrame(out));
33
+ });
34
+ }
35
+ },
36
+ },
37
+ });
38
+ };
39
+
40
+ const authOk = (frame: SourceRconFrame, reply: Reply) => {
41
+ reply({
42
+ id: frame.id,
43
+ type: SourceRconResponse.Value,
44
+ body: "",
45
+ });
46
+ reply({
47
+ id: frame.id,
48
+ type: SourceRconResponse.Auth,
49
+ body: "",
50
+ });
51
+ };
52
+
53
+ describe("sourceRcon", () => {
54
+ test("rejects a wrong password with an auth-failed error", async () => {
55
+ const server = rconServer((frame, reply) => {
56
+ if (frame.type !== SourceRconRequest.Auth) {
57
+ return;
58
+ }
59
+
60
+ reply({
61
+ id: frame.id,
62
+ type: SourceRconResponse.Value,
63
+ body: "",
64
+ });
65
+ reply({
66
+ id: SOURCE_RCON_AUTH_FAILED_ID,
67
+ type: SourceRconResponse.Auth,
68
+ body: "",
69
+ });
70
+ });
71
+
72
+ try {
73
+ const error = await sourceRcon({
74
+ host: HOST,
75
+ port: server.port,
76
+ password: "wrong",
77
+ timeoutMs: 2000,
78
+ }).catch((reason: unknown) => reason);
79
+
80
+ expect(error).toBeInstanceOf(BridgeRconError);
81
+ expect((error as BridgeRconError).code).toBe(BridgeRconCode.AuthFailed);
82
+ } finally {
83
+ server.stop(true);
84
+ }
85
+ });
86
+
87
+ test("authenticates and returns a single-packet reply", async () => {
88
+ const server = rconServer((frame, reply) => {
89
+ if (frame.type === SourceRconRequest.Auth) {
90
+ authOk(frame, reply);
91
+
92
+ return;
93
+ }
94
+
95
+ reply({
96
+ id: frame.id,
97
+ type: SourceRconResponse.Value,
98
+ body: frame.body.length === 0 ? "" : `ran ${frame.body}`,
99
+ });
100
+ });
101
+
102
+ try {
103
+ const client = await sourceRcon({
104
+ host: HOST,
105
+ port: server.port,
106
+ password: "right",
107
+ timeoutMs: 2000,
108
+ });
109
+
110
+ expect(await client.command("ListPlayers")).toBe("ran ListPlayers");
111
+
112
+ client.close();
113
+ } finally {
114
+ server.stop(true);
115
+ }
116
+ });
117
+
118
+ test("joins a multi-packet reply at the probe echo", async () => {
119
+ const server = rconServer((frame, reply) => {
120
+ if (frame.type === SourceRconRequest.Auth) {
121
+ authOk(frame, reply);
122
+
123
+ return;
124
+ }
125
+
126
+ if (frame.type === SourceRconResponse.Value) {
127
+ reply({
128
+ id: frame.id,
129
+ type: SourceRconResponse.Value,
130
+ body: "",
131
+ });
132
+
133
+ return;
134
+ }
135
+
136
+ for (const part of [
137
+ "first-",
138
+ "second-",
139
+ "third",
140
+ ]) {
141
+ reply({
142
+ id: frame.id,
143
+ type: SourceRconResponse.Value,
144
+ body: part,
145
+ });
146
+ }
147
+ });
148
+
149
+ try {
150
+ const client = await sourceRcon({
151
+ host: HOST,
152
+ port: server.port,
153
+ password: "right",
154
+ timeoutMs: 2000,
155
+ });
156
+
157
+ expect(await client.command("status")).toBe("first-second-third");
158
+
159
+ client.close();
160
+ } finally {
161
+ server.stop(true);
162
+ }
163
+ });
164
+
165
+ test("settles on the first value frame when multi-packet is off", async () => {
166
+ let probes = 0;
167
+
168
+ const server = rconServer((frame, reply) => {
169
+ if (frame.type === SourceRconRequest.Auth) {
170
+ authOk(frame, reply);
171
+
172
+ return;
173
+ }
174
+
175
+ if (frame.type === SourceRconResponse.Value) {
176
+ probes += 1;
177
+
178
+ return;
179
+ }
180
+
181
+ reply({
182
+ id: frame.id,
183
+ type: SourceRconResponse.Value,
184
+ body: "single",
185
+ });
186
+ });
187
+
188
+ try {
189
+ const client = await sourceRcon({
190
+ host: HOST,
191
+ port: server.port,
192
+ password: "right",
193
+ timeoutMs: 2000,
194
+ multiPacket: false,
195
+ });
196
+
197
+ expect(await client.command("/players")).toBe("single");
198
+ expect(probes).toBe(0);
199
+
200
+ client.close();
201
+ } finally {
202
+ server.stop(true);
203
+ }
204
+ });
205
+
206
+ test("runs queued commands one after another", async () => {
207
+ const server = rconServer((frame, reply) => {
208
+ if (frame.type === SourceRconRequest.Auth) {
209
+ authOk(frame, reply);
210
+
211
+ return;
212
+ }
213
+
214
+ reply({
215
+ id: frame.id,
216
+ type: SourceRconResponse.Value,
217
+ body: frame.body.length === 0 ? "" : frame.body.toUpperCase(),
218
+ });
219
+ });
220
+
221
+ try {
222
+ const client = await sourceRcon({
223
+ host: HOST,
224
+ port: server.port,
225
+ password: "right",
226
+ timeoutMs: 2000,
227
+ });
228
+
229
+ expect(
230
+ await Promise.all([
231
+ client.command("one"),
232
+ client.command("two"),
233
+ client.command("three"),
234
+ ]),
235
+ ).toEqual([
236
+ "ONE",
237
+ "TWO",
238
+ "THREE",
239
+ ]);
240
+
241
+ client.close();
242
+ } finally {
243
+ server.stop(true);
244
+ }
245
+ });
246
+
247
+ test("times out a command the server never answers", async () => {
248
+ const server = rconServer((frame, reply) => {
249
+ if (frame.type === SourceRconRequest.Auth) {
250
+ authOk(frame, reply);
251
+ }
252
+ });
253
+
254
+ try {
255
+ const client = await sourceRcon({
256
+ host: HOST,
257
+ port: server.port,
258
+ password: "right",
259
+ timeoutMs: 250,
260
+ });
261
+
262
+ const error = await client.command("status").catch((reason: unknown) => reason);
263
+
264
+ expect(error).toBeInstanceOf(BridgeRconError);
265
+ expect((error as BridgeRconError).code).toBe(BridgeRconCode.TimedOut);
266
+ } finally {
267
+ server.stop(true);
268
+ }
269
+ });
270
+
271
+ test("refuses a command body above the packet limit", async () => {
272
+ const server = rconServer((frame, reply) => {
273
+ if (frame.type === SourceRconRequest.Auth) {
274
+ authOk(frame, reply);
275
+ }
276
+ });
277
+
278
+ try {
279
+ const client = await sourceRcon({
280
+ host: HOST,
281
+ port: server.port,
282
+ password: "right",
283
+ timeoutMs: 2000,
284
+ });
285
+
286
+ const error = await client.command("x".repeat(5000)).catch((reason: unknown) => reason);
287
+
288
+ expect(error).toBeInstanceOf(BridgeRconError);
289
+ expect((error as BridgeRconError).code).toBe(BridgeRconCode.Oversized);
290
+
291
+ client.close();
292
+ } finally {
293
+ server.stop(true);
294
+ }
295
+ });
296
+
297
+ test("reports an unreachable port", async () => {
298
+ const error = await sourceRcon({
299
+ host: HOST,
300
+ port: 59_431,
301
+ password: "right",
302
+ timeoutMs: 1000,
303
+ }).catch((reason: unknown) => reason);
304
+
305
+ expect(error).toBeInstanceOf(BridgeRconError);
306
+ expect((error as BridgeRconError).code).toBe(BridgeRconCode.Unreachable);
307
+ });
308
+ });
@@ -0,0 +1,276 @@
1
+ import { BridgeRconCode, BridgeRconError } from "../core";
2
+ import {
3
+ concatBytes,
4
+ decodeSourceRconFrames,
5
+ encodeSourceRconFrame,
6
+ SOURCE_RCON_AUTH_FAILED_ID,
7
+ type SourceRconFrame,
8
+ SourceRconRequest,
9
+ SourceRconResponse,
10
+ } from "./sourceRconFrame";
11
+
12
+ const DEFAULT_HOST = "127.0.0.1";
13
+
14
+ const DEFAULT_TIMEOUT_MS = 10_000;
15
+
16
+ const AUTH_ID = 1;
17
+
18
+ const RESPONSE_LIMIT = 1024 * 1024;
19
+
20
+ export interface SourceRconOptions {
21
+ host?: string;
22
+ port: number;
23
+ password: string;
24
+ timeoutMs?: number;
25
+ multiPacket?: boolean;
26
+ }
27
+
28
+ export interface SourceRconClient {
29
+ command(text: string): Promise<string>;
30
+ close(): void;
31
+ }
32
+
33
+ interface Waiter {
34
+ id: number;
35
+ auth: boolean;
36
+ probeId: number | null;
37
+ parts: string[];
38
+ settle(outcome: string | BridgeRconError): void;
39
+ }
40
+
41
+ const rconError = (code: BridgeRconCode, message: string, cause?: unknown) => {
42
+ if (cause instanceof BridgeRconError) {
43
+ return cause;
44
+ }
45
+
46
+ const detail = cause instanceof Error && cause.message.length > 0 ? ` — ${cause.message}` : "";
47
+
48
+ return new BridgeRconError(code, `${message}${detail}`);
49
+ };
50
+
51
+ export const sourceRcon = async (options: SourceRconOptions): Promise<SourceRconClient> => {
52
+ const host = options.host ?? DEFAULT_HOST;
53
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
54
+ const multiPacket = options.multiPacket !== false;
55
+ const target = `${host}:${options.port}`;
56
+
57
+ let buffer: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
58
+ let waiter: Waiter | null = null;
59
+ let failure: BridgeRconError | null = null;
60
+ let nextId = AUTH_ID + 1;
61
+ let queue: Promise<unknown> = Promise.resolve();
62
+
63
+ const fail = (error: BridgeRconError) => {
64
+ failure ??= error;
65
+
66
+ const waiting = waiter;
67
+
68
+ waiter = null;
69
+ waiting?.settle(error);
70
+ };
71
+
72
+ const accept = (frame: SourceRconFrame) => {
73
+ const waiting = waiter;
74
+
75
+ if (!waiting) {
76
+ return;
77
+ }
78
+
79
+ if (waiting.auth) {
80
+ if (frame.type !== SourceRconResponse.Auth) {
81
+ return;
82
+ }
83
+
84
+ waiter = null;
85
+
86
+ waiting.settle(
87
+ frame.id === SOURCE_RCON_AUTH_FAILED_ID
88
+ ? new BridgeRconError(BridgeRconCode.AuthFailed, `${target} rejected the rcon password`)
89
+ : "",
90
+ );
91
+
92
+ return;
93
+ }
94
+
95
+ if (frame.type !== SourceRconResponse.Value) {
96
+ return;
97
+ }
98
+
99
+ if (waiting.probeId !== null && frame.id === waiting.probeId) {
100
+ waiter = null;
101
+ waiting.settle(waiting.parts.join(""));
102
+
103
+ return;
104
+ }
105
+
106
+ if (frame.id !== waiting.id) {
107
+ return;
108
+ }
109
+
110
+ waiting.parts.push(frame.body);
111
+
112
+ if (waiting.parts.reduce((total, part) => total + part.length, 0) > RESPONSE_LIMIT) {
113
+ fail(
114
+ new BridgeRconError(
115
+ BridgeRconCode.Malformed,
116
+ `${target} sent an rcon reply larger than ${RESPONSE_LIMIT} bytes`,
117
+ ),
118
+ );
119
+
120
+ return;
121
+ }
122
+
123
+ if (waiting.probeId === null) {
124
+ waiter = null;
125
+ waiting.settle(waiting.parts.join(""));
126
+ }
127
+ };
128
+
129
+ const receive = (chunk: Uint8Array) => {
130
+ buffer = concatBytes(buffer, chunk);
131
+
132
+ try {
133
+ const decoded = decodeSourceRconFrames(buffer);
134
+
135
+ buffer = decoded.rest;
136
+
137
+ for (const frame of decoded.frames) {
138
+ accept(frame);
139
+ }
140
+ } catch (error) {
141
+ fail(rconError(BridgeRconCode.Malformed, `${target} sent an unreadable rcon packet`, error));
142
+ }
143
+ };
144
+
145
+ const connect = async () => {
146
+ let guard: ReturnType<typeof setTimeout> | undefined;
147
+
148
+ try {
149
+ return await Promise.race([
150
+ Bun.connect({
151
+ hostname: host,
152
+ port: options.port,
153
+ socket: {
154
+ data(_socket, data) {
155
+ receive(data);
156
+ },
157
+ close() {
158
+ fail(new BridgeRconError(BridgeRconCode.Closed, `${target} closed the rcon connection`));
159
+ },
160
+ error(_socket, error) {
161
+ fail(rconError(BridgeRconCode.Unreachable, `the rcon connection to ${target} failed`, error));
162
+ },
163
+ },
164
+ }),
165
+ new Promise<never>((_resolve, reject) => {
166
+ guard = setTimeout(() => {
167
+ reject(new BridgeRconError(BridgeRconCode.TimedOut, `the rcon connection to ${target} timed out`));
168
+ }, timeoutMs);
169
+ }),
170
+ ]);
171
+ } catch (error) {
172
+ throw rconError(BridgeRconCode.Unreachable, `the rcon connection to ${target} could not be opened`, error);
173
+ } finally {
174
+ clearTimeout(guard);
175
+ }
176
+ };
177
+
178
+ const socket = await connect();
179
+
180
+ const exchange = (id: number, type: SourceRconRequest, body: string, probeId: number | null) => {
181
+ return new Promise<string>((resolve, reject) => {
182
+ if (failure) {
183
+ reject(failure);
184
+
185
+ return;
186
+ }
187
+
188
+ let settled = false;
189
+
190
+ const timer = setTimeout(() => {
191
+ fail(new BridgeRconError(BridgeRconCode.TimedOut, `${target} did not answer the rcon request in time`));
192
+ socket.end();
193
+ }, timeoutMs);
194
+
195
+ waiter = {
196
+ id,
197
+ auth: type === SourceRconRequest.Auth,
198
+ probeId,
199
+ parts: [],
200
+ settle(outcome) {
201
+ if (settled) {
202
+ return;
203
+ }
204
+
205
+ settled = true;
206
+
207
+ clearTimeout(timer);
208
+
209
+ if (outcome instanceof BridgeRconError) {
210
+ reject(outcome);
211
+
212
+ return;
213
+ }
214
+
215
+ resolve(outcome);
216
+ },
217
+ };
218
+
219
+ try {
220
+ socket.write(
221
+ encodeSourceRconFrame({
222
+ id,
223
+ type,
224
+ body,
225
+ }),
226
+ );
227
+
228
+ if (probeId !== null) {
229
+ socket.write(
230
+ encodeSourceRconFrame({
231
+ id: probeId,
232
+ type: SourceRconResponse.Value,
233
+ body: "",
234
+ }),
235
+ );
236
+ }
237
+ } catch (error) {
238
+ fail(rconError(BridgeRconCode.Closed, `the rcon request to ${target} could not be sent`, error));
239
+ }
240
+ });
241
+ };
242
+
243
+ const serialize = <Result>(work: () => Promise<Result>): Promise<Result> => {
244
+ const next = queue.then(work, work);
245
+
246
+ queue = next.then(
247
+ () => undefined,
248
+ () => undefined,
249
+ );
250
+
251
+ return next;
252
+ };
253
+
254
+ try {
255
+ await serialize(() => exchange(AUTH_ID, SourceRconRequest.Auth, options.password, null));
256
+ } catch (error) {
257
+ socket.end();
258
+
259
+ throw error;
260
+ }
261
+
262
+ return {
263
+ async command(text) {
264
+ return await serialize(async () => {
265
+ const id = nextId++;
266
+ const probeId = multiPacket ? nextId++ : null;
267
+
268
+ return await exchange(id, SourceRconRequest.Command, text, probeId);
269
+ });
270
+ },
271
+
272
+ close() {
273
+ socket.end();
274
+ },
275
+ };
276
+ };
@@ -0,0 +1,152 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { BridgeRconCode, BridgeRconError } from "../core";
3
+ import {
4
+ concatBytes,
5
+ decodeSourceRconFrames,
6
+ encodeSourceRconFrame,
7
+ SOURCE_RCON_BODY_LIMIT,
8
+ SourceRconRequest,
9
+ SourceRconResponse,
10
+ } from "./sourceRconFrame";
11
+
12
+ describe("encodeSourceRconFrame", () => {
13
+ test("writes size, id and type as little endian int32 followed by two nulls", () => {
14
+ const packet = encodeSourceRconFrame({
15
+ id: 7,
16
+ type: SourceRconRequest.Auth,
17
+ body: "pass",
18
+ });
19
+
20
+ const view = new DataView(packet.buffer);
21
+
22
+ expect(packet).toHaveLength(18);
23
+ expect(view.getInt32(0, true)).toBe(14);
24
+ expect(view.getInt32(4, true)).toBe(7);
25
+ expect(view.getInt32(8, true)).toBe(3);
26
+ expect(new TextDecoder().decode(packet.subarray(12, 16))).toBe("pass");
27
+ expect(packet.at(16)).toBe(0);
28
+ expect(packet.at(17)).toBe(0);
29
+ });
30
+
31
+ test("refuses a body above the 4096 byte cap", () => {
32
+ const error = (() => {
33
+ try {
34
+ encodeSourceRconFrame({
35
+ id: 1,
36
+ type: SourceRconRequest.Command,
37
+ body: "x".repeat(SOURCE_RCON_BODY_LIMIT + 1),
38
+ });
39
+
40
+ return null;
41
+ } catch (reason: unknown) {
42
+ return reason;
43
+ }
44
+ })();
45
+
46
+ expect(error).toBeInstanceOf(BridgeRconError);
47
+ expect((error as BridgeRconError).code).toBe(BridgeRconCode.Oversized);
48
+ });
49
+ });
50
+
51
+ describe("decodeSourceRconFrames", () => {
52
+ test("round-trips a frame", () => {
53
+ const decoded = decodeSourceRconFrames(
54
+ encodeSourceRconFrame({
55
+ id: 3,
56
+ type: SourceRconResponse.Value,
57
+ body: "hello",
58
+ }),
59
+ );
60
+
61
+ expect(decoded.frames).toEqual([
62
+ {
63
+ id: 3,
64
+ type: 0,
65
+ body: "hello",
66
+ },
67
+ ]);
68
+ expect(decoded.rest).toHaveLength(0);
69
+ });
70
+
71
+ test("reads several frames out of one buffer", () => {
72
+ const decoded = decodeSourceRconFrames(
73
+ concatBytes(
74
+ encodeSourceRconFrame({
75
+ id: 1,
76
+ type: SourceRconResponse.Value,
77
+ body: "a",
78
+ }),
79
+ encodeSourceRconFrame({
80
+ id: 2,
81
+ type: SourceRconResponse.Auth,
82
+ body: "",
83
+ }),
84
+ ),
85
+ );
86
+
87
+ expect(decoded.frames.map((frame) => frame.id)).toEqual([
88
+ 1,
89
+ 2,
90
+ ]);
91
+ });
92
+
93
+ test("keeps a partial frame for the next chunk", () => {
94
+ const packet = encodeSourceRconFrame({
95
+ id: 9,
96
+ type: SourceRconResponse.Value,
97
+ body: "split",
98
+ });
99
+
100
+ const first = decodeSourceRconFrames(packet.subarray(0, 8));
101
+
102
+ expect(first.frames).toEqual([]);
103
+ expect(first.rest).toHaveLength(8);
104
+
105
+ const second = decodeSourceRconFrames(concatBytes(first.rest, packet.subarray(8)));
106
+
107
+ expect(second.frames).toEqual([
108
+ {
109
+ id: 9,
110
+ type: 0,
111
+ body: "split",
112
+ },
113
+ ]);
114
+ });
115
+
116
+ test("reads an empty body", () => {
117
+ expect(
118
+ decodeSourceRconFrames(
119
+ encodeSourceRconFrame({
120
+ id: -1,
121
+ type: SourceRconResponse.Auth,
122
+ body: "",
123
+ }),
124
+ ).frames,
125
+ ).toEqual([
126
+ {
127
+ id: -1,
128
+ type: 2,
129
+ body: "",
130
+ },
131
+ ]);
132
+ });
133
+
134
+ test("rejects a packet declaring an impossible size", () => {
135
+ const packet = new Uint8Array(14);
136
+
137
+ new DataView(packet.buffer).setInt32(0, 2, true);
138
+
139
+ const error = (() => {
140
+ try {
141
+ decodeSourceRconFrames(packet);
142
+
143
+ return null;
144
+ } catch (reason: unknown) {
145
+ return reason;
146
+ }
147
+ })();
148
+
149
+ expect(error).toBeInstanceOf(BridgeRconError);
150
+ expect((error as BridgeRconError).code).toBe(BridgeRconCode.Malformed);
151
+ });
152
+ });