@serverkgg/bridge 0.0.10 → 0.0.12

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,344 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { BridgeProbeCode, BridgeProbeError } from "../core";
3
+ import { parseStatus, probeMinecraftPing } from "./minecraftPing";
4
+
5
+ const HOST = "127.0.0.1";
6
+
7
+ const CLOSED_PORT = 59_441;
8
+
9
+ const varInt = (value: number) => {
10
+ const bytes: number[] = [];
11
+
12
+ let current = value >>> 0;
13
+
14
+ while (true) {
15
+ if ((current & ~0x7f) === 0) {
16
+ bytes.push(current);
17
+
18
+ return bytes;
19
+ }
20
+
21
+ bytes.push((current & 0x7f) | 0x80);
22
+
23
+ current >>>= 7;
24
+ }
25
+ };
26
+
27
+ const readVarInt = (view: Uint8Array, offset: number) => {
28
+ let value = 0;
29
+ let size = 0;
30
+
31
+ while (size < 5) {
32
+ const byte = view[offset + size];
33
+
34
+ if (byte === undefined) {
35
+ throw new Error("varint is truncated");
36
+ }
37
+
38
+ value |= (byte & 0x7f) << (7 * size);
39
+ size += 1;
40
+
41
+ if ((byte & 0x80) === 0) {
42
+ return {
43
+ value,
44
+ size,
45
+ };
46
+ }
47
+ }
48
+
49
+ throw new Error("varint is malformed");
50
+ };
51
+
52
+ const response = (payload: unknown, packetId = 0x00) => {
53
+ const json = new TextEncoder().encode(JSON.stringify(payload));
54
+
55
+ const body = [
56
+ ...varInt(packetId),
57
+ ...varInt(json.length),
58
+ ...json,
59
+ ];
60
+
61
+ return new Uint8Array([
62
+ ...varInt(body.length),
63
+ ...body,
64
+ ]);
65
+ };
66
+
67
+ const status = {
68
+ players: {
69
+ online: 3,
70
+ max: 20,
71
+ },
72
+ version: {
73
+ name: "1.21.11",
74
+ },
75
+ description: "A Serverk Minecraft Server",
76
+ };
77
+
78
+ const handshakeServerName = (frame: Uint8Array) => {
79
+ const length = readVarInt(frame, 0);
80
+ const packetId = readVarInt(frame, length.size);
81
+ const protocol = readVarInt(frame, length.size + packetId.size);
82
+ const address = readVarInt(frame, length.size + packetId.size + protocol.size);
83
+ const start = length.size + packetId.size + protocol.size + address.size;
84
+
85
+ return new TextDecoder().decode(frame.subarray(start, start + address.value));
86
+ };
87
+
88
+ const listen = async (reply: Uint8Array | null, onServerName?: (serverName: string) => void) => {
89
+ return Bun.listen({
90
+ hostname: HOST,
91
+ port: 0,
92
+ socket: {
93
+ data(socket, data) {
94
+ if (onServerName) {
95
+ onServerName(handshakeServerName(data));
96
+ }
97
+
98
+ if (reply) {
99
+ socket.write(reply);
100
+ }
101
+ },
102
+ error() {},
103
+ },
104
+ });
105
+ };
106
+
107
+ describe("reading a status response off the wire", () => {
108
+ test("a complete response gives the player counts, the version and the motd", () => {
109
+ expect(parseStatus(response(status))).toEqual({
110
+ players: {
111
+ online: 3,
112
+ max: 20,
113
+ },
114
+ version: "1.21.11",
115
+ description: "A Serverk Minecraft Server",
116
+ });
117
+ });
118
+
119
+ test("a response longer than one packet is still read, because the frame carries its own length", () => {
120
+ const packet = response(status);
121
+ const padded = new Uint8Array(packet.length + 4);
122
+
123
+ padded.set(packet);
124
+
125
+ expect(parseStatus(padded)?.players).toEqual({
126
+ online: 3,
127
+ max: 20,
128
+ });
129
+ });
130
+
131
+ test("a modern description object is read through its text", () => {
132
+ expect(
133
+ parseStatus(
134
+ response({
135
+ ...status,
136
+ description: {
137
+ text: "hello",
138
+ },
139
+ }),
140
+ )?.description,
141
+ ).toBe("hello");
142
+ });
143
+
144
+ test("a description we cannot read is dropped rather than failing the sample", () => {
145
+ expect(
146
+ parseStatus(
147
+ response({
148
+ ...status,
149
+ description: {
150
+ extra: [],
151
+ },
152
+ }),
153
+ )?.description,
154
+ ).toBeNull();
155
+ expect(
156
+ parseStatus(
157
+ response({
158
+ ...status,
159
+ description: null,
160
+ }),
161
+ )?.description,
162
+ ).toBeNull();
163
+ });
164
+
165
+ test("a missing version name is dropped rather than failing the sample", () => {
166
+ expect(
167
+ parseStatus(
168
+ response({
169
+ players: status.players,
170
+ }),
171
+ )?.version,
172
+ ).toBeNull();
173
+ });
174
+
175
+ test("fractional or negative counts are clamped to whole players", () => {
176
+ expect(
177
+ parseStatus(
178
+ response({
179
+ players: {
180
+ online: -1,
181
+ max: 20.7,
182
+ },
183
+ }),
184
+ )?.players,
185
+ ).toEqual({
186
+ online: 0,
187
+ max: 20,
188
+ });
189
+ });
190
+
191
+ test("a response we have not fully received yet asks for more bytes", () => {
192
+ const packet = response(status);
193
+
194
+ expect(parseStatus(new Uint8Array(0))).toBeNull();
195
+ expect(parseStatus(packet.subarray(0, 1))).toBeNull();
196
+ expect(parseStatus(packet.subarray(0, packet.length - 4))).toBeNull();
197
+ });
198
+
199
+ test("a large payload whose length needs two varint bytes is read whole", () => {
200
+ const long = {
201
+ players: status.players,
202
+ description: "n".repeat(400),
203
+ };
204
+
205
+ expect(parseStatus(response(long))?.description).toBe(long.description);
206
+ });
207
+
208
+ test("a packet that is not a status response is refused", () => {
209
+ expect(() => parseStatus(response(status, 0x01))).toThrow();
210
+ });
211
+
212
+ test("a response with no player counts is refused, because a sample without them is a lie", () => {
213
+ expect(() =>
214
+ parseStatus(
215
+ response({
216
+ version: {
217
+ name: "1.21.11",
218
+ },
219
+ }),
220
+ ),
221
+ ).toThrow();
222
+ });
223
+ });
224
+
225
+ describe("probeMinecraftPing", () => {
226
+ test("answers with the status a listening server sends back", async () => {
227
+ const server = await listen(response(status));
228
+
229
+ try {
230
+ const sample = await probeMinecraftPing(
231
+ {
232
+ address: HOST,
233
+ port: server.port,
234
+ },
235
+ 2000,
236
+ );
237
+
238
+ expect(sample.players).toEqual({
239
+ online: 3,
240
+ max: 20,
241
+ });
242
+ expect(sample.version).toBe("1.21.11");
243
+ expect(sample.latencyMs).toBeGreaterThanOrEqual(0);
244
+ } finally {
245
+ server.stop(true);
246
+ }
247
+ });
248
+
249
+ test("puts the server name in the handshake so a virtual host still answers", async () => {
250
+ const seen: string[] = [];
251
+
252
+ const server = await listen(response(status), (serverName) => {
253
+ seen.push(serverName);
254
+ });
255
+
256
+ try {
257
+ await probeMinecraftPing(
258
+ {
259
+ address: HOST,
260
+ port: server.port,
261
+ serverName: "play.example.com",
262
+ },
263
+ 2000,
264
+ );
265
+
266
+ expect(seen.at(0)).toBe("play.example.com");
267
+ } finally {
268
+ server.stop(true);
269
+ }
270
+ });
271
+
272
+ test("falls back to the address when no server name is given", async () => {
273
+ const seen: string[] = [];
274
+
275
+ const server = await listen(response(status), (serverName) => {
276
+ seen.push(serverName);
277
+ });
278
+
279
+ try {
280
+ await probeMinecraftPing(
281
+ {
282
+ address: HOST,
283
+ port: server.port,
284
+ },
285
+ 2000,
286
+ );
287
+
288
+ expect(seen.at(0)).toBe(HOST);
289
+ } finally {
290
+ server.stop(true);
291
+ }
292
+ });
293
+
294
+ test("rejects as unreachable when nothing listens on the port", async () => {
295
+ const error = await probeMinecraftPing(
296
+ {
297
+ address: HOST,
298
+ port: CLOSED_PORT,
299
+ },
300
+ 2000,
301
+ ).catch((reason: unknown) => reason);
302
+
303
+ expect(error).toBeInstanceOf(BridgeProbeError);
304
+ expect((error as BridgeProbeError).code).toBe(BridgeProbeCode.Unreachable);
305
+ });
306
+
307
+ test("times out against a port that never answers", async () => {
308
+ const silent = await listen(null);
309
+
310
+ try {
311
+ const error = await probeMinecraftPing(
312
+ {
313
+ address: HOST,
314
+ port: silent.port,
315
+ },
316
+ 250,
317
+ ).catch((reason: unknown) => reason);
318
+
319
+ expect(error).toBeInstanceOf(BridgeProbeError);
320
+ expect((error as BridgeProbeError).code).toBe(BridgeProbeCode.TimedOut);
321
+ } finally {
322
+ silent.stop(true);
323
+ }
324
+ });
325
+
326
+ test("rejects a reply that is not a status response", async () => {
327
+ const noisy = await listen(response(status, 0x01));
328
+
329
+ try {
330
+ const error = await probeMinecraftPing(
331
+ {
332
+ address: HOST,
333
+ port: noisy.port,
334
+ },
335
+ 2000,
336
+ ).catch((reason: unknown) => reason);
337
+
338
+ expect(error).toBeInstanceOf(BridgeProbeError);
339
+ expect((error as BridgeProbeError).code).toBe(BridgeProbeCode.Malformed);
340
+ } finally {
341
+ noisy.stop(true);
342
+ }
343
+ });
344
+ });
@@ -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
+ };