@serverkgg/bridge 0.0.11 → 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.
@@ -52,49 +52,65 @@ const compileTags = (guideTags: GuideTag[], tags: string[]): CompiledGuideTag[]
52
52
  });
53
53
  };
54
54
 
55
- export const compileGuides = (source: PackageSource, guideTags: GuideTag[], entries: GuideEntry[]): GuidePack => {
56
- const drafts: DraftGuide[] = [];
55
+ export interface MarkdownBodyReader {
56
+ files: string[];
57
+ commands: string[];
58
+ images: Map<string, GuideImageAsset>;
59
+ read(path: string, text?: string): ParsedBlock[];
60
+ }
61
+
62
+ export const markdownBodyReader = (source: PackageSource): MarkdownBodyReader => {
57
63
  const files: string[] = [];
58
64
  const commands: string[] = [];
59
- const imageByPath = new Map<string, GuideImageAsset>();
65
+ const images = new Map<string, GuideImageAsset>();
60
66
 
61
- const readBody = (path: string) => {
62
- const body = source.read(path);
67
+ return {
68
+ files,
69
+ commands,
70
+ images,
71
+ read(path, text) {
72
+ const body = source.read(path);
63
73
 
64
- if (!body) {
65
- throw new Error(`missing guide body ${path}`);
66
- }
74
+ if (!body) {
75
+ throw new Error(`missing markdown body ${path}`);
76
+ }
67
77
 
68
- files.push(path);
78
+ files.push(path);
69
79
 
70
- const parsed = parseGuideMarkdown(path, new TextDecoder().decode(body));
80
+ const parsed = parseGuideMarkdown(path, text ?? new TextDecoder().decode(body));
71
81
 
72
- commands.push(...parsed.commands);
82
+ commands.push(...parsed.commands);
73
83
 
74
- for (const imagePath of parsed.images) {
75
- if (imageByPath.has(imagePath)) {
76
- continue;
77
- }
84
+ for (const imagePath of parsed.images) {
85
+ if (images.has(imagePath)) {
86
+ continue;
87
+ }
78
88
 
79
- const imageBytes = source.read(imagePath);
89
+ const imageBytes = source.read(imagePath);
80
90
 
81
- if (!imageBytes) {
82
- throw new Error(`missing guide image ${imagePath}`);
83
- }
91
+ if (!imageBytes) {
92
+ throw new Error(`missing guide image ${imagePath}`);
93
+ }
84
94
 
85
- const verified = verifyAsset(imagePath, imageBytes);
95
+ const verified = verifyAsset(imagePath, imageBytes);
86
96
 
87
- imageByPath.set(imagePath, {
88
- path: imagePath,
89
- bytes: imageBytes,
90
- mediaType: verified.mediaType,
91
- width: verified.dimensions?.width ?? null,
92
- height: verified.dimensions?.height ?? null,
93
- });
94
- }
97
+ images.set(imagePath, {
98
+ path: imagePath,
99
+ bytes: imageBytes,
100
+ mediaType: verified.mediaType,
101
+ width: verified.dimensions?.width ?? null,
102
+ height: verified.dimensions?.height ?? null,
103
+ });
104
+ }
95
105
 
96
- return parsed.blocks;
106
+ return parsed.blocks;
107
+ },
97
108
  };
109
+ };
110
+
111
+ export const compileGuides = (source: PackageSource, guideTags: GuideTag[], entries: GuideEntry[]): GuidePack => {
112
+ const drafts: DraftGuide[] = [];
113
+ const reader = markdownBodyReader(source);
98
114
 
99
115
  for (const entry of entries) {
100
116
  drafts.push({
@@ -105,8 +121,8 @@ export const compileGuides = (source: PackageSource, guideTags: GuideTag[], entr
105
121
  surfaces: entry.surfaces,
106
122
  videoId: entry.video ?? null,
107
123
  body: {
108
- ar: readBody(entry.bodyPath.ar),
109
- en: readBody(entry.bodyPath.en),
124
+ ar: reader.read(entry.bodyPath.ar),
125
+ en: reader.read(entry.bodyPath.en),
110
126
  },
111
127
  });
112
128
  }
@@ -114,13 +130,13 @@ export const compileGuides = (source: PackageSource, guideTags: GuideTag[], entr
114
130
  return {
115
131
  drafts,
116
132
  files: [
117
- ...files,
118
- ...imageByPath.keys(),
133
+ ...reader.files,
134
+ ...reader.images.keys(),
119
135
  ],
120
136
  images: [
121
- ...imageByPath.values(),
137
+ ...reader.images.values(),
122
138
  ],
123
- commands,
139
+ commands: reader.commands,
124
140
  };
125
141
  };
126
142
 
@@ -142,10 +158,7 @@ export interface ResolvedGuideImage {
142
158
  height: number | null;
143
159
  }
144
160
 
145
- export const resolveGuideImages = (
146
- drafts: DraftGuide[],
147
- resolved: Map<string, ResolvedGuideImage>,
148
- ): CompiledGuide[] => {
161
+ export const resolveGuideBlocks = (blocks: ParsedBlock[], resolved: Map<string, ResolvedGuideImage>): GuideBlock[] => {
149
162
  const resolveLeaf = (block: ParsedLeafBlock): GuideLeafBlock => {
150
163
  if (block.type !== "image") {
151
164
  return block;
@@ -166,27 +179,30 @@ export const resolveGuideImages = (
166
179
  };
167
180
  };
168
181
 
169
- const resolveBlocks = (blocks: ParsedBlock[]): GuideBlock[] => {
170
- return blocks.map((block) => {
171
- if (block.type !== "conditional") {
172
- return resolveLeaf(block);
173
- }
182
+ return blocks.map((block) => {
183
+ if (block.type !== "conditional") {
184
+ return resolveLeaf(block);
185
+ }
174
186
 
175
- return {
176
- type: "conditional",
177
- key: block.key,
178
- when: block.when.map(resolveLeaf),
179
- otherwise: block.otherwise.map(resolveLeaf),
180
- };
181
- });
182
- };
187
+ return {
188
+ type: "conditional",
189
+ key: block.key,
190
+ when: block.when.map(resolveLeaf),
191
+ otherwise: block.otherwise.map(resolveLeaf),
192
+ };
193
+ });
194
+ };
183
195
 
196
+ export const resolveGuideImages = (
197
+ drafts: DraftGuide[],
198
+ resolved: Map<string, ResolvedGuideImage>,
199
+ ): CompiledGuide[] => {
184
200
  return drafts.map((draft) => {
185
201
  return compiledGuideSchema.parse({
186
202
  ...draft,
187
203
  body: {
188
- ar: resolveBlocks(draft.body.ar),
189
- en: resolveBlocks(draft.body.en),
204
+ ar: resolveGuideBlocks(draft.body.ar, resolved),
205
+ en: resolveGuideBlocks(draft.body.en, resolved),
190
206
  },
191
207
  });
192
208
  });
@@ -10,6 +10,26 @@ const text = (value: string) => ({
10
10
  en: value,
11
11
  });
12
12
 
13
+ const guide = (id: string, extra: Record<string, unknown> = {}) => ({
14
+ id,
15
+ title: text(id),
16
+ summary: text(id),
17
+ bodyPath: {
18
+ ar: `guides/${id}/ar.md`,
19
+ en: `guides/${id}/en.md`,
20
+ },
21
+ ...extra,
22
+ });
23
+
24
+ const guidePackage = (guides: unknown[]) => ({
25
+ schemaVersion: MANIFEST_SCHEMA_VERSION,
26
+ kind: "GuidePackage",
27
+ metadata: {
28
+ id: "platform",
29
+ },
30
+ guides,
31
+ });
32
+
13
33
  const base = () => ({
14
34
  schemaVersion: MANIFEST_SCHEMA_VERSION,
15
35
  kind: "GamePackage",
@@ -586,6 +606,19 @@ describe("packageSchema", () => {
586
606
  expect(result.success).toBe(false);
587
607
  expect(result.error && z.prettifyError(result.error)).toContain('tag "start" is not declared in guideTags');
588
608
  });
609
+
610
+ test("carries up to 64 guides", () => {
611
+ const many = (count: number) =>
612
+ Array.from(
613
+ {
614
+ length: count,
615
+ },
616
+ (_, index) => guide(`guide-${index}`),
617
+ );
618
+
619
+ expect(guidePackageSchema.safeParse(guidePackage(many(64))).success).toBe(true);
620
+ expect(guidePackageSchema.safeParse(guidePackage(many(65))).success).toBe(false);
621
+ });
589
622
  });
590
623
 
591
624
  describe("presence", () => {
@@ -1 +1,10 @@
1
+ export type {
2
+ BridgeA2sInfo,
3
+ BridgeMinecraftPingStatus,
4
+ BridgePingPlayers,
5
+ BridgeRaknetPingStatus,
6
+ } from "../context";
7
+ export { BridgeProbeCode, BridgeProbeError } from "../core";
1
8
  export * from "./a2s";
9
+ export * from "./minecraftPing";
10
+ export * from "./raknetPing";
@@ -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
+ });