@serverkgg/bridge 0.0.11 → 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 (84) hide show
  1. package/package.json +11 -1
  2. package/src/blog/articleFrontmatter.test.ts +95 -0
  3. package/src/blog/articleFrontmatter.ts +99 -0
  4. package/src/blog/compile.test.ts +148 -0
  5. package/src/blog/compile.ts +172 -0
  6. package/src/blog/compiled.ts +22 -0
  7. package/src/blog/index.ts +3 -0
  8. package/src/catalogs/curseforge.test.ts +580 -0
  9. package/src/catalogs/curseforge.ts +461 -0
  10. package/src/catalogs/index.ts +2 -0
  11. package/src/catalogs/thunderstore.test.ts +94 -0
  12. package/src/catalogs/thunderstore.ts +239 -0
  13. package/src/cli/validate.ts +7 -0
  14. package/src/codecs/index.ts +3 -0
  15. package/src/codecs/ini.test.ts +152 -0
  16. package/src/codecs/ini.ts +158 -0
  17. package/src/codecs/sourceCfg.test.ts +100 -0
  18. package/src/codecs/sourceCfg.ts +111 -0
  19. package/src/codecs/xmlProperties.test.ts +104 -0
  20. package/src/codecs/xmlProperties.ts +133 -0
  21. package/src/context/bridgeContext.ts +40 -0
  22. package/src/core/bridgeRconError.ts +19 -0
  23. package/src/core/index.ts +1 -0
  24. package/src/guides/compile.ts +70 -54
  25. package/src/install/index.ts +1 -0
  26. package/src/install/installStamp.test.ts +125 -0
  27. package/src/install/installStamp.ts +40 -0
  28. package/src/main.ts +1 -0
  29. package/src/manifest/index.ts +1 -0
  30. package/src/manifest/manifest.test.ts +460 -2
  31. package/src/manifest/manifest.ts +141 -13
  32. package/src/manifest/primitives.ts +6 -0
  33. package/src/manifest/runtime.ts +89 -0
  34. package/src/net/index.ts +2 -0
  35. package/src/net/loopback.test.ts +152 -0
  36. package/src/net/loopback.ts +109 -0
  37. package/src/net/loopbackMethod.ts +7 -0
  38. package/src/presence/index.ts +1 -0
  39. package/src/presence/rosterSync.test.ts +179 -0
  40. package/src/presence/rosterSync.ts +57 -0
  41. package/src/probes/index.ts +9 -0
  42. package/src/probes/minecraftPing.test.ts +344 -0
  43. package/src/probes/minecraftPing.ts +283 -0
  44. package/src/probes/raknetPing.test.ts +197 -0
  45. package/src/probes/raknetPing.ts +228 -0
  46. package/src/protocol/bridgeEvents.ts +4 -0
  47. package/src/rcon/index.ts +3 -0
  48. package/src/rcon/sourceRcon.test.ts +308 -0
  49. package/src/rcon/sourceRcon.ts +276 -0
  50. package/src/rcon/sourceRconFrame.test.ts +152 -0
  51. package/src/rcon/sourceRconFrame.ts +99 -0
  52. package/src/runtime/bridgeCodec.ts +45 -2
  53. package/src/runtime/bridgeContext.ts +13 -6
  54. package/src/runtime/bridgeDriver.test.ts +1 -0
  55. package/src/runtime/bridgeLiveConfig.test.ts +149 -0
  56. package/src/runtime/bridgeLiveConfig.ts +63 -0
  57. package/src/runtime/bridgeProbe.ts +13 -1
  58. package/src/runtime/bridgeRcon.ts +15 -0
  59. package/src/runtime/bridgeRegistry.ts +1 -0
  60. package/src/steam/index.ts +7 -0
  61. package/src/steam/steamApp.test.ts +78 -0
  62. package/src/steam/steamApp.ts +35 -0
  63. package/src/steam/steamcmd.test.ts +157 -0
  64. package/src/steam/steamcmd.ts +273 -0
  65. package/src/steam/steamcmdLogin.ts +34 -0
  66. package/src/steam/steamcmdNarrator.test.ts +90 -0
  67. package/src/steam/steamcmdNarrator.ts +43 -0
  68. package/src/steam/steamcmdPlatform.ts +4 -0
  69. package/src/steam/webApi.ts +70 -0
  70. package/src/steam/workshop.test.ts +45 -0
  71. package/src/steam/workshop.ts +148 -0
  72. package/src/utils/execDetail.ts +16 -0
  73. package/src/utils/index.ts +2 -0
  74. package/src/utils/token.test.ts +50 -0
  75. package/src/utils/token.ts +35 -0
  76. package/src/wine/index.ts +3 -0
  77. package/src/wine/protonCommand.test.ts +67 -0
  78. package/src/wine/protonCommand.ts +49 -0
  79. package/src/wine/wineCommand.test.ts +92 -0
  80. package/src/wine/wineCommand.ts +54 -0
  81. package/src/wine/winePrefix.test.ts +111 -0
  82. package/src/wine/winePrefix.ts +88 -0
  83. package/src/runtime/bridgeSecrets.test.ts +0 -121
  84. package/src/runtime/bridgeSecrets.ts +0 -43
@@ -0,0 +1,283 @@
1
+ import type { BridgeMinecraftPingStatus } from "../context";
2
+ import { BridgeProbeCode, BridgeProbeError } from "../core";
3
+
4
+ const STATUS_PROTOCOL_VERSION = -1;
5
+
6
+ const STATUS_STATE = 1;
7
+
8
+ const MAX_RESPONSE_BYTES = 256 * 1024;
9
+
10
+ const VARINT_MAX_BYTES = 5;
11
+
12
+ export interface BridgeMinecraftPingTarget {
13
+ address: string;
14
+ port: number;
15
+ serverName?: string;
16
+ }
17
+
18
+ interface StatusPayload {
19
+ players?: {
20
+ online?: unknown;
21
+ max?: unknown;
22
+ };
23
+ version?: {
24
+ name?: unknown;
25
+ };
26
+ description?: unknown;
27
+ }
28
+
29
+ interface DescriptionObject {
30
+ text?: unknown;
31
+ }
32
+
33
+ const writeVarInt = (value: number) => {
34
+ const bytes: number[] = [];
35
+
36
+ let current = value >>> 0;
37
+
38
+ while (true) {
39
+ if ((current & ~0x7f) === 0) {
40
+ bytes.push(current);
41
+
42
+ return bytes;
43
+ }
44
+
45
+ bytes.push((current & 0x7f) | 0x80);
46
+
47
+ current >>>= 7;
48
+ }
49
+ };
50
+
51
+ const readVarInt = (view: Uint8Array, offset: number) => {
52
+ let value = 0;
53
+ let size = 0;
54
+
55
+ while (size < VARINT_MAX_BYTES) {
56
+ const byte = view[offset + size];
57
+
58
+ if (byte === undefined) {
59
+ return null;
60
+ }
61
+
62
+ value |= (byte & 0x7f) << (7 * size);
63
+ size += 1;
64
+
65
+ if ((byte & 0x80) === 0) {
66
+ return {
67
+ value,
68
+ size,
69
+ };
70
+ }
71
+ }
72
+
73
+ throw new Error("minecraft ping varint is malformed");
74
+ };
75
+
76
+ const framed = (body: number[]) => {
77
+ return new Uint8Array([
78
+ ...writeVarInt(body.length),
79
+ ...body,
80
+ ]);
81
+ };
82
+
83
+ const handshake = (serverName: string, hostPort: number) => {
84
+ const address = new TextEncoder().encode(serverName);
85
+
86
+ return framed([
87
+ 0x00,
88
+ ...writeVarInt(STATUS_PROTOCOL_VERSION),
89
+ ...writeVarInt(address.length),
90
+ ...address,
91
+ (hostPort >> 8) & 0xff,
92
+ hostPort & 0xff,
93
+ ...writeVarInt(STATUS_STATE),
94
+ ]);
95
+ };
96
+
97
+ const STATUS_REQUEST = framed([
98
+ 0x00,
99
+ ]);
100
+
101
+ const toDescription = (description: unknown): string | null => {
102
+ if (typeof description === "string") {
103
+ return description;
104
+ }
105
+
106
+ if (description === null || typeof description !== "object") {
107
+ return null;
108
+ }
109
+
110
+ const text = (description as DescriptionObject).text;
111
+
112
+ return typeof text === "string" ? text : null;
113
+ };
114
+
115
+ export const parseStatus = (view: Uint8Array): Omit<BridgeMinecraftPingStatus, "latencyMs"> | null => {
116
+ const frame = readVarInt(view, 0);
117
+
118
+ if (!frame) {
119
+ return null;
120
+ }
121
+
122
+ if (view.length < frame.size + frame.value) {
123
+ return null;
124
+ }
125
+
126
+ const packetId = readVarInt(view, frame.size);
127
+
128
+ if (!packetId) {
129
+ return null;
130
+ }
131
+
132
+ if (packetId.value !== 0x00) {
133
+ throw new Error("minecraft ping response has an unexpected packet id");
134
+ }
135
+
136
+ const length = readVarInt(view, frame.size + packetId.size);
137
+
138
+ if (!length) {
139
+ return null;
140
+ }
141
+
142
+ const start = frame.size + packetId.size + length.size;
143
+
144
+ if (view.length < start + length.value) {
145
+ return null;
146
+ }
147
+
148
+ const status = JSON.parse(new TextDecoder().decode(view.subarray(start, start + length.value))) as StatusPayload;
149
+
150
+ const online = status.players?.online;
151
+ const max = status.players?.max;
152
+
153
+ if (typeof online !== "number" || typeof max !== "number") {
154
+ throw new Error("minecraft ping response is missing player counts");
155
+ }
156
+
157
+ const version = status.version?.name;
158
+
159
+ return {
160
+ players: {
161
+ online: Math.max(0, Math.floor(online)),
162
+ max: Math.max(0, Math.floor(max)),
163
+ },
164
+ version: typeof version === "string" ? version : null,
165
+ description: toDescription(status.description),
166
+ };
167
+ };
168
+
169
+ const probeError = (code: BridgeProbeCode, message: string, cause: unknown) => {
170
+ if (cause instanceof BridgeProbeError) {
171
+ return cause;
172
+ }
173
+
174
+ const detail = cause instanceof Error && cause.message.length > 0 ? `: ${cause.message}` : "";
175
+
176
+ return new BridgeProbeError(code, `${message}${detail}`);
177
+ };
178
+
179
+ export const probeMinecraftPing = (target: BridgeMinecraftPingTarget, timeoutMs: number) => {
180
+ const label = `${target.address}:${target.port}`;
181
+ const serverName = target.serverName ?? target.address;
182
+
183
+ return new Promise<BridgeMinecraftPingStatus>((resolve, reject) => {
184
+ const startedAt = performance.now();
185
+
186
+ let socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
187
+ let received = new Uint8Array(0);
188
+ let settled = false;
189
+
190
+ const finish = (outcome: Omit<BridgeMinecraftPingStatus, "latencyMs"> | BridgeProbeError) => {
191
+ if (settled) {
192
+ return;
193
+ }
194
+
195
+ settled = true;
196
+
197
+ clearTimeout(timer);
198
+
199
+ try {
200
+ socket?.end();
201
+ } catch {}
202
+
203
+ if (outcome instanceof BridgeProbeError) {
204
+ reject(outcome);
205
+
206
+ return;
207
+ }
208
+
209
+ resolve({
210
+ ...outcome,
211
+ latencyMs: Math.round(performance.now() - startedAt),
212
+ });
213
+ };
214
+
215
+ const timer = setTimeout(() => {
216
+ finish(new BridgeProbeError(BridgeProbeCode.TimedOut, `minecraft ping to ${label} timed out`));
217
+ }, timeoutMs);
218
+
219
+ const handle = (chunk: Uint8Array) => {
220
+ if (received.length + chunk.length > MAX_RESPONSE_BYTES) {
221
+ finish(new BridgeProbeError(BridgeProbeCode.Malformed, `minecraft ping response from ${label} is too large`));
222
+
223
+ return;
224
+ }
225
+
226
+ const merged = new Uint8Array(received.length + chunk.length);
227
+
228
+ merged.set(received);
229
+ merged.set(chunk, received.length);
230
+
231
+ received = merged;
232
+
233
+ try {
234
+ const status = parseStatus(received);
235
+
236
+ if (status) {
237
+ finish(status);
238
+ }
239
+ } catch (error) {
240
+ finish(probeError(BridgeProbeCode.Malformed, `minecraft ping response from ${label} could not be read`, error));
241
+ }
242
+ };
243
+
244
+ Bun.connect({
245
+ hostname: target.address,
246
+ port: target.port,
247
+ socket: {
248
+ data(_socket, data) {
249
+ handle(data);
250
+ },
251
+ error(_socket, error) {
252
+ finish(probeError(BridgeProbeCode.Unreachable, `minecraft ping to ${label} failed`, error));
253
+ },
254
+ close() {
255
+ finish(
256
+ new BridgeProbeError(BridgeProbeCode.Unreachable, `minecraft ping to ${label} closed before answering`),
257
+ );
258
+ },
259
+ },
260
+ })
261
+ .then((created) => {
262
+ if (settled) {
263
+ created.end();
264
+
265
+ return;
266
+ }
267
+
268
+ socket = created;
269
+
270
+ try {
271
+ created.write(handshake(serverName, target.port));
272
+ created.write(STATUS_REQUEST);
273
+ } catch (error) {
274
+ finish(probeError(BridgeProbeCode.Unreachable, `minecraft ping to ${label} could not be sent`, error));
275
+ }
276
+ })
277
+ .catch((error) => {
278
+ finish(
279
+ probeError(BridgeProbeCode.Unreachable, `minecraft ping socket for ${label} could not be opened`, error),
280
+ );
281
+ });
282
+ });
283
+ };
@@ -0,0 +1,197 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { BridgeProbeCode, BridgeProbeError } from "../core";
3
+ import { parseUnconnectedPong, probeRaknetPing } from "./raknetPing";
4
+
5
+ const HOST = "127.0.0.1";
6
+
7
+ const CLOSED_PORT = 59_443;
8
+
9
+ const MAGIC = [
10
+ 0x00,
11
+ 0xff,
12
+ 0xff,
13
+ 0x00,
14
+ 0xfe,
15
+ 0xfe,
16
+ 0xfe,
17
+ 0xfe,
18
+ 0xfd,
19
+ 0xfd,
20
+ 0xfd,
21
+ 0xfd,
22
+ 0x12,
23
+ 0x34,
24
+ 0x56,
25
+ 0x78,
26
+ ];
27
+
28
+ const ADVERT = "MCPE;Serverk Bedrock;800;1.21.60;4;10;13253860808220445253;بيت الشباب;Survival;1;19132;19133";
29
+
30
+ const pong = (
31
+ advert: string,
32
+ options: {
33
+ header?: number;
34
+ magic?: number[];
35
+ length?: number;
36
+ } = {},
37
+ ) => {
38
+ const payload = new TextEncoder().encode(advert);
39
+ const length = options.length ?? payload.length;
40
+
41
+ return new Uint8Array([
42
+ options.header ?? 0x1c,
43
+ ...new Array(8).fill(0),
44
+ ...new Array(8).fill(1),
45
+ ...(options.magic ?? MAGIC),
46
+ (length >> 8) & 0xff,
47
+ length & 0xff,
48
+ ...payload,
49
+ ]);
50
+ };
51
+
52
+ const listen = async (reply: Uint8Array | null) => {
53
+ return await Bun.udpSocket({
54
+ hostname: HOST,
55
+ port: 0,
56
+ socket: {
57
+ data(socket, _data, port, address) {
58
+ if (reply) {
59
+ socket.send(reply, port, address);
60
+ }
61
+ },
62
+ },
63
+ });
64
+ };
65
+
66
+ describe("reading an unconnected pong", () => {
67
+ test("a full advertisement gives the players, the version and the motd", () => {
68
+ expect(parseUnconnectedPong(pong(ADVERT))).toEqual({
69
+ players: {
70
+ online: 4,
71
+ max: 10,
72
+ },
73
+ edition: "MCPE",
74
+ motd: "Serverk Bedrock",
75
+ subMotd: "بيت الشباب",
76
+ version: "1.21.60",
77
+ protocol: 800,
78
+ gamemode: "Survival",
79
+ });
80
+ });
81
+
82
+ test("an advertisement that stops after the player counts is still read", () => {
83
+ expect(parseUnconnectedPong(pong("MCPE;Serverk;800;1.21.60;2;8"))).toEqual({
84
+ players: {
85
+ online: 2,
86
+ max: 8,
87
+ },
88
+ edition: "MCPE",
89
+ motd: "Serverk",
90
+ subMotd: null,
91
+ version: "1.21.60",
92
+ protocol: 800,
93
+ gamemode: null,
94
+ });
95
+ });
96
+
97
+ test("a pong with another packet id is refused", () => {
98
+ expect(() =>
99
+ parseUnconnectedPong(
100
+ pong(ADVERT, {
101
+ header: 0x1d,
102
+ }),
103
+ ),
104
+ ).toThrow();
105
+ });
106
+
107
+ test("a pong without the raknet magic is refused", () => {
108
+ expect(() =>
109
+ parseUnconnectedPong(
110
+ pong(ADVERT, {
111
+ magic: new Array(16).fill(0),
112
+ }),
113
+ ),
114
+ ).toThrow();
115
+ });
116
+
117
+ test("a pong that advertises more payload than it carries is refused", () => {
118
+ expect(() =>
119
+ parseUnconnectedPong(
120
+ pong(ADVERT, {
121
+ length: 4096,
122
+ }),
123
+ ),
124
+ ).toThrow();
125
+ });
126
+
127
+ test("a pong without player counts is refused, because a sample without them is a lie", () => {
128
+ expect(() => parseUnconnectedPong(pong("MCPE;Serverk;800;1.21.60"))).toThrow();
129
+ });
130
+
131
+ test("a datagram larger than a pong can be is refused", () => {
132
+ expect(() => parseUnconnectedPong(pong("n".repeat(4096)))).toThrow();
133
+ });
134
+
135
+ test("a truncated datagram is refused", () => {
136
+ expect(() => parseUnconnectedPong(new Uint8Array(8))).toThrow();
137
+ });
138
+ });
139
+
140
+ describe("probeRaknetPing", () => {
141
+ test("answers with the advertisement a listening server sends back", async () => {
142
+ const server = await listen(pong(ADVERT));
143
+
144
+ try {
145
+ const sample = await probeRaknetPing(HOST, server.port, 2000);
146
+
147
+ expect(sample.players).toEqual({
148
+ online: 4,
149
+ max: 10,
150
+ });
151
+ expect(sample.motd).toBe("Serverk Bedrock");
152
+ expect(sample.latencyMs).toBeGreaterThanOrEqual(0);
153
+ } finally {
154
+ server.close();
155
+ }
156
+ });
157
+
158
+ test("rejects with an unreachable probe error when nothing listens on the port", async () => {
159
+ const probe = probeRaknetPing(HOST, CLOSED_PORT, 2000);
160
+
161
+ await expect(probe).rejects.toBeInstanceOf(BridgeProbeError);
162
+ });
163
+
164
+ test("times out against a port that never answers", async () => {
165
+ const silent = await listen(null);
166
+
167
+ try {
168
+ const error = await probeRaknetPing(HOST, silent.port, 250).catch((reason: unknown) => reason);
169
+
170
+ expect(error).toBeInstanceOf(BridgeProbeError);
171
+ expect((error as BridgeProbeError).code).toBe(BridgeProbeCode.TimedOut);
172
+ } finally {
173
+ silent.close();
174
+ }
175
+ });
176
+
177
+ test("rejects a reply that is not a raknet pong", async () => {
178
+ const noisy = await listen(
179
+ new Uint8Array([
180
+ 1,
181
+ 2,
182
+ 3,
183
+ 4,
184
+ 5,
185
+ ]),
186
+ );
187
+
188
+ try {
189
+ const error = await probeRaknetPing(HOST, noisy.port, 2000).catch((reason: unknown) => reason);
190
+
191
+ expect(error).toBeInstanceOf(BridgeProbeError);
192
+ expect((error as BridgeProbeError).code).toBe(BridgeProbeCode.Malformed);
193
+ } finally {
194
+ noisy.close();
195
+ }
196
+ });
197
+ });
@@ -0,0 +1,228 @@
1
+ import type { BridgeRaknetPingStatus } from "../context";
2
+ import { BridgeProbeCode, BridgeProbeError } from "../core";
3
+
4
+ const UNCONNECTED_PING = 0x01;
5
+
6
+ const UNCONNECTED_PONG = 0x1c;
7
+
8
+ const MAGIC = new Uint8Array([
9
+ 0x00,
10
+ 0xff,
11
+ 0xff,
12
+ 0x00,
13
+ 0xfe,
14
+ 0xfe,
15
+ 0xfe,
16
+ 0xfe,
17
+ 0xfd,
18
+ 0xfd,
19
+ 0xfd,
20
+ 0xfd,
21
+ 0x12,
22
+ 0x34,
23
+ 0x56,
24
+ 0x78,
25
+ ]);
26
+
27
+ const REQUEST_BYTES = 33;
28
+
29
+ const MAGIC_OFFSET = 17;
30
+
31
+ const LENGTH_OFFSET = MAGIC_OFFSET + MAGIC.length;
32
+
33
+ const STRING_OFFSET = LENGTH_OFFSET + 2;
34
+
35
+ const MAX_RESPONSE_BYTES = 2 * 1024;
36
+
37
+ const FIELD_EDITION = 0;
38
+
39
+ const FIELD_MOTD = 1;
40
+
41
+ const FIELD_PROTOCOL = 2;
42
+
43
+ const FIELD_VERSION = 3;
44
+
45
+ const FIELD_ONLINE = 4;
46
+
47
+ const FIELD_MAX = 5;
48
+
49
+ const FIELD_SUB_MOTD = 7;
50
+
51
+ const FIELD_GAMEMODE = 8;
52
+
53
+ const DECODER = new TextDecoder();
54
+
55
+ const pingRequest = () => {
56
+ const request = new Uint8Array(REQUEST_BYTES);
57
+ const view = new DataView(request.buffer);
58
+
59
+ const guid = new BigUint64Array(1);
60
+
61
+ crypto.getRandomValues(guid);
62
+
63
+ request[0] = UNCONNECTED_PING;
64
+
65
+ view.setBigInt64(1, BigInt(Math.trunc(performance.timeOrigin + performance.now())), false);
66
+ request.set(MAGIC, 9);
67
+ view.setBigUint64(25, guid[0] ?? 0n, false);
68
+
69
+ return request;
70
+ };
71
+
72
+ const text = (fields: string[], index: number): string | null => {
73
+ const value = fields[index]?.trim();
74
+
75
+ return value !== undefined && value.length > 0 ? value : null;
76
+ };
77
+
78
+ const count = (fields: string[], index: number): number | null => {
79
+ const value = text(fields, index);
80
+
81
+ if (value === null) {
82
+ return null;
83
+ }
84
+
85
+ const parsed = Number.parseInt(value, 10);
86
+
87
+ return Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : null;
88
+ };
89
+
90
+ export const parseUnconnectedPong = (view: Uint8Array): Omit<BridgeRaknetPingStatus, "latencyMs"> => {
91
+ if (view.length > MAX_RESPONSE_BYTES) {
92
+ throw new Error("raknet pong is larger than a pong can be");
93
+ }
94
+
95
+ if (view.length < STRING_OFFSET || view[0] !== UNCONNECTED_PONG) {
96
+ throw new Error("raknet pong has an unexpected header");
97
+ }
98
+
99
+ if (!MAGIC.every((byte, index) => view[MAGIC_OFFSET + index] === byte)) {
100
+ throw new Error("raknet pong does not carry the raknet magic");
101
+ }
102
+
103
+ const high = view[LENGTH_OFFSET];
104
+ const low = view[LENGTH_OFFSET + 1];
105
+
106
+ if (high === undefined || low === undefined) {
107
+ throw new Error("raknet pong is truncated");
108
+ }
109
+
110
+ const length = (high << 8) | low;
111
+
112
+ if (view.length < STRING_OFFSET + length) {
113
+ throw new Error("raknet pong advertises more payload than it carries");
114
+ }
115
+
116
+ const fields = DECODER.decode(view.subarray(STRING_OFFSET, STRING_OFFSET + length)).split(";");
117
+
118
+ const online = count(fields, FIELD_ONLINE);
119
+ const max = count(fields, FIELD_MAX);
120
+
121
+ if (online === null || max === null) {
122
+ throw new Error("raknet pong is missing player counts");
123
+ }
124
+
125
+ return {
126
+ players: {
127
+ online,
128
+ max,
129
+ },
130
+ edition: text(fields, FIELD_EDITION),
131
+ motd: text(fields, FIELD_MOTD),
132
+ subMotd: text(fields, FIELD_SUB_MOTD),
133
+ version: text(fields, FIELD_VERSION),
134
+ protocol: count(fields, FIELD_PROTOCOL),
135
+ gamemode: text(fields, FIELD_GAMEMODE),
136
+ };
137
+ };
138
+
139
+ const probeError = (code: BridgeProbeCode, message: string, cause: unknown) => {
140
+ if (cause instanceof BridgeProbeError) {
141
+ return cause;
142
+ }
143
+
144
+ const detail = cause instanceof Error && cause.message.length > 0 ? `: ${cause.message}` : "";
145
+
146
+ return new BridgeProbeError(code, `${message}${detail}`);
147
+ };
148
+
149
+ export const probeRaknetPing = (host: string, hostPort: number, timeoutMs: number) => {
150
+ const target = `${host}:${hostPort}`;
151
+
152
+ return new Promise<BridgeRaknetPingStatus>((resolve, reject) => {
153
+ const startedAt = performance.now();
154
+
155
+ let socket: Awaited<ReturnType<typeof Bun.udpSocket>> | null = null;
156
+ let settled = false;
157
+
158
+ const finish = (outcome: Omit<BridgeRaknetPingStatus, "latencyMs"> | BridgeProbeError) => {
159
+ if (settled) {
160
+ return;
161
+ }
162
+
163
+ settled = true;
164
+
165
+ clearTimeout(timer);
166
+
167
+ try {
168
+ socket?.close();
169
+ } catch {}
170
+
171
+ if (outcome instanceof BridgeProbeError) {
172
+ reject(outcome);
173
+
174
+ return;
175
+ }
176
+
177
+ resolve({
178
+ ...outcome,
179
+ latencyMs: Math.round(performance.now() - startedAt),
180
+ });
181
+ };
182
+
183
+ const timer = setTimeout(() => {
184
+ finish(new BridgeProbeError(BridgeProbeCode.TimedOut, `raknet ping to ${target} timed out`));
185
+ }, timeoutMs);
186
+
187
+ const handle = (view: Uint8Array) => {
188
+ try {
189
+ finish(parseUnconnectedPong(view));
190
+ } catch (error) {
191
+ finish(probeError(BridgeProbeCode.Malformed, `raknet pong from ${target} could not be read`, error));
192
+ }
193
+ };
194
+
195
+ Bun.udpSocket({
196
+ connect: {
197
+ hostname: host,
198
+ port: hostPort,
199
+ },
200
+ socket: {
201
+ data(_socket, data) {
202
+ handle(data);
203
+ },
204
+ error(_socket, error) {
205
+ finish(probeError(BridgeProbeCode.Unreachable, `raknet ping to ${target} failed`, error));
206
+ },
207
+ },
208
+ })
209
+ .then((created) => {
210
+ if (settled) {
211
+ created.close();
212
+
213
+ return;
214
+ }
215
+
216
+ socket = created;
217
+
218
+ try {
219
+ created.send(pingRequest());
220
+ } catch (error) {
221
+ finish(probeError(BridgeProbeCode.Unreachable, `raknet ping to ${target} could not be sent`, error));
222
+ }
223
+ })
224
+ .catch((error) => {
225
+ finish(probeError(BridgeProbeCode.Unreachable, `raknet socket for ${target} could not be opened`, error));
226
+ });
227
+ });
228
+ };
@@ -3,14 +3,18 @@ export enum BridgeEventName {
3
3
  MemoryUndersized = "MemoryUndersized",
4
4
  MissingDependency = "MissingDependency",
5
5
  ModCrashed = "ModCrashed",
6
+ ModLoaded = "ModLoaded",
6
7
  PlayerAdvanced = "PlayerAdvanced",
8
+ PlayerBanned = "PlayerBanned",
7
9
  PlayerDied = "PlayerDied",
8
10
  PlayerJoined = "PlayerJoined",
11
+ PlayerKicked = "PlayerKicked",
9
12
  PlayerLeft = "PlayerLeft",
10
13
  PortBindFailed = "PortBindFailed",
11
14
  ServerCrashed = "ServerCrashed",
12
15
  ServerStarted = "ServerStarted",
13
16
  ServerStopping = "ServerStopping",
17
+ ServerUpdated = "ServerUpdated",
14
18
  TickLagging = "TickLagging",
15
19
  WorldCorrupt = "WorldCorrupt",
16
20
  WorldSaved = "WorldSaved",
@@ -0,0 +1,3 @@
1
+ export { BridgeRconCode, BridgeRconError } from "../core";
2
+ export * from "./sourceRcon";
3
+ export * from "./sourceRconFrame";