@serverkgg/bridge 0.0.13 → 0.0.15

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 (39) hide show
  1. package/package.json +1 -1
  2. package/src/cli/validate.test.ts +194 -2
  3. package/src/cli/validate.ts +83 -1
  4. package/src/context/bridgeContext.ts +9 -2
  5. package/src/guides/variables.ts +1 -0
  6. package/src/manifest/manifest.ts +1 -1
  7. package/src/modules/bridgeModule.ts +2 -1
  8. package/src/panel/bridgePanel.ts +11 -0
  9. package/src/panel/bridgePlace.ts +4 -0
  10. package/src/panel/index.ts +1 -0
  11. package/src/rcon/index.ts +3 -2
  12. package/src/rcon/rconAccess.test.ts +304 -0
  13. package/src/rcon/rconAccess.ts +260 -0
  14. package/src/rcon/rconClient.test.ts +1544 -0
  15. package/src/rcon/rconClient.ts +275 -0
  16. package/src/rcon/rconError.ts +21 -0
  17. package/src/rcon/rconFrame.test.ts +269 -0
  18. package/src/rcon/{sourceRconFrame.ts → rconFrame.ts} +23 -14
  19. package/src/rcon/rconIds.test.ts +59 -0
  20. package/src/rcon/rconIds.ts +33 -0
  21. package/src/rcon/rconLink.ts +427 -0
  22. package/src/rcon/rconWriter.test.ts +95 -0
  23. package/src/rcon/rconWriter.ts +63 -0
  24. package/src/runtime/bridgeConsoleHandler.test.ts +196 -0
  25. package/src/runtime/bridgeConsoleHandler.ts +26 -0
  26. package/src/runtime/bridgeDriver.test.ts +145 -0
  27. package/src/runtime/bridgeDriver.ts +44 -2
  28. package/src/runtime/bridgeRcon.ts +6 -2
  29. package/src/runtime/bridgeRegistry.test.ts +96 -0
  30. package/src/runtime/bridgeRegistry.ts +33 -1
  31. package/src/runtime/serve.ts +10 -1
  32. package/src/steam/avatars.test.ts +306 -0
  33. package/src/steam/avatars.ts +107 -0
  34. package/src/steam/index.ts +1 -0
  35. package/src/wire/panel.test.ts +79 -0
  36. package/src/wire/panel.ts +11 -0
  37. package/src/rcon/sourceRcon.test.ts +0 -308
  38. package/src/rcon/sourceRcon.ts +0 -276
  39. package/src/rcon/sourceRconFrame.test.ts +0 -152
@@ -0,0 +1,306 @@
1
+ import { afterEach, describe, expect, setSystemTime, test } from "bun:test";
2
+ import type { BridgeContext } from "../context";
3
+ import type { BridgeValues } from "../core";
4
+ import { createSteamAvatars, STEAM_AVATAR_CACHE_TTL_MS, steamAvatarHashOf } from "./avatars";
5
+
6
+ const ALICE_HASH = "a".repeat(40);
7
+
8
+ const BOB_HASH = "b".repeat(40);
9
+
10
+ const ALICE = "76561198000000001";
11
+
12
+ const BOB = "76561198000000002";
13
+
14
+ const identity = (id: string) => (id.startsWith("7656") ? id : null);
15
+
16
+ interface Warning {
17
+ message: string;
18
+ fields: BridgeValues | undefined;
19
+ }
20
+
21
+ interface Summary {
22
+ steamid: string;
23
+ avatarfull: string;
24
+ }
25
+
26
+ interface FakeSteam {
27
+ context: BridgeContext;
28
+ calls: string[][];
29
+ warnings: Warning[];
30
+ }
31
+
32
+ const summaryOf = (steamId: string, hash: string): Summary => {
33
+ return {
34
+ steamid: steamId,
35
+ avatarfull: `https://avatars.steamstatic.com/${hash}_full.jpg`,
36
+ };
37
+ };
38
+
39
+ const fakeSteam = (options: { key?: string | null; answer?: (steamIds: string[]) => Summary[] }): FakeSteam => {
40
+ const calls: string[][] = [];
41
+ const warnings: Warning[] = [];
42
+
43
+ const context = {
44
+ secret(key: string) {
45
+ return key === "STEAM_WEB_API_KEY" ? (options.key === undefined ? "secret-key" : options.key) : null;
46
+ },
47
+ net: {
48
+ async json(url: string) {
49
+ const steamIds = (new URL(url).searchParams.get("steamids") ?? "").split(",").filter((id) => id.length > 0);
50
+
51
+ calls.push(steamIds);
52
+
53
+ return {
54
+ response: {
55
+ players: options.answer?.(steamIds) ?? [],
56
+ },
57
+ };
58
+ },
59
+ },
60
+ log: {
61
+ warn(message: string, fields?: BridgeValues) {
62
+ warnings.push({
63
+ message,
64
+ fields,
65
+ });
66
+ },
67
+ },
68
+ } as unknown as BridgeContext;
69
+
70
+ return {
71
+ context,
72
+ calls,
73
+ warnings,
74
+ };
75
+ };
76
+
77
+ afterEach(() => {
78
+ setSystemTime();
79
+ });
80
+
81
+ describe("reading the avatar hash out of a steam profile picture", () => {
82
+ test("takes the hash out of the full-size url steam answers with", () => {
83
+ expect(steamAvatarHashOf(`https://avatars.steamstatic.com/${ALICE_HASH}_full.jpg`)).toBe(ALICE_HASH);
84
+ });
85
+
86
+ test("takes the hash out of a url that carries no size suffix", () => {
87
+ expect(steamAvatarHashOf(`https://avatars.steamstatic.com/${BOB_HASH}.jpg`)).toBe(BOB_HASH);
88
+ });
89
+
90
+ test("reports no hash for a url that is not a steam avatar", () => {
91
+ expect(steamAvatarHashOf("https://example.com/avatar.png")).toBeNull();
92
+ expect(steamAvatarHashOf("")).toBeNull();
93
+ });
94
+ });
95
+
96
+ describe("keeping the steam avatars a roster shows", () => {
97
+ test("asks steam for a hash it has never seen, and answers with it", async () => {
98
+ const steam = fakeSteam({
99
+ answer: () => [
100
+ summaryOf(ALICE, ALICE_HASH),
101
+ ],
102
+ });
103
+ const avatars = createSteamAvatars({
104
+ steamIdOf: identity,
105
+ });
106
+
107
+ expect(
108
+ await avatars.resolve(steam.context, [
109
+ ALICE,
110
+ ]),
111
+ ).toEqual(
112
+ new Map([
113
+ [
114
+ ALICE,
115
+ ALICE_HASH,
116
+ ],
117
+ ]),
118
+ );
119
+ expect(steam.calls).toEqual([
120
+ [
121
+ ALICE,
122
+ ],
123
+ ]);
124
+ });
125
+
126
+ test("answers the second time without asking steam again", async () => {
127
+ const steam = fakeSteam({
128
+ answer: () => [
129
+ summaryOf(ALICE, ALICE_HASH),
130
+ ],
131
+ });
132
+ const avatars = createSteamAvatars({
133
+ steamIdOf: identity,
134
+ });
135
+
136
+ await avatars.resolve(steam.context, [
137
+ ALICE,
138
+ ]);
139
+
140
+ expect(
141
+ (
142
+ await avatars.resolve(steam.context, [
143
+ ALICE,
144
+ ])
145
+ ).get(ALICE),
146
+ ).toBe(ALICE_HASH);
147
+ expect(steam.calls.length).toBe(1);
148
+ });
149
+
150
+ test("asks only for the ids it is missing, and still answers for the cached one", async () => {
151
+ const steam = fakeSteam({
152
+ answer: (steamIds) => {
153
+ return steamIds.map((steamId) => {
154
+ return summaryOf(steamId, steamId.endsWith("1") ? ALICE_HASH : BOB_HASH);
155
+ });
156
+ },
157
+ });
158
+ const avatars = createSteamAvatars({
159
+ steamIdOf: identity,
160
+ });
161
+
162
+ await avatars.resolve(steam.context, [
163
+ ALICE,
164
+ ]);
165
+
166
+ const hashes = await avatars.resolve(steam.context, [
167
+ ALICE,
168
+ BOB,
169
+ ]);
170
+
171
+ expect(steam.calls.at(1)).toEqual([
172
+ BOB,
173
+ ]);
174
+ expect(hashes.get(ALICE)).toBe(ALICE_HASH);
175
+ expect(hashes.get(BOB)).toBe(BOB_HASH);
176
+ });
177
+
178
+ test("asks steam again once the cached hash is six hours old", async () => {
179
+ setSystemTime(new Date(0));
180
+
181
+ const steam = fakeSteam({
182
+ answer: () => [
183
+ summaryOf(ALICE, ALICE_HASH),
184
+ ],
185
+ });
186
+ const avatars = createSteamAvatars({
187
+ steamIdOf: identity,
188
+ });
189
+
190
+ await avatars.resolve(steam.context, [
191
+ ALICE,
192
+ ]);
193
+
194
+ setSystemTime(new Date(STEAM_AVATAR_CACHE_TTL_MS - 1));
195
+
196
+ await avatars.resolve(steam.context, [
197
+ ALICE,
198
+ ]);
199
+
200
+ expect(steam.calls.length).toBe(1);
201
+
202
+ setSystemTime(new Date(STEAM_AVATAR_CACHE_TTL_MS));
203
+
204
+ expect(
205
+ (
206
+ await avatars.resolve(steam.context, [
207
+ ALICE,
208
+ ])
209
+ ).get(ALICE),
210
+ ).toBe(ALICE_HASH);
211
+ expect(steam.calls.length).toBe(2);
212
+ });
213
+
214
+ test("never asks steam about a player the game does not map to a steam id", async () => {
215
+ const steam = fakeSteam({});
216
+ const avatars = createSteamAvatars({
217
+ steamIdOf: identity,
218
+ });
219
+
220
+ expect(
221
+ await avatars.resolve(steam.context, [
222
+ "gdk_2535000000000001",
223
+ ]),
224
+ ).toEqual(new Map());
225
+ expect(steam.calls).toEqual([]);
226
+ });
227
+
228
+ test("answers with nothing while the platform has no steam key", async () => {
229
+ const steam = fakeSteam({
230
+ key: null,
231
+ });
232
+ const avatars = createSteamAvatars({
233
+ steamIdOf: identity,
234
+ });
235
+
236
+ expect(
237
+ await avatars.resolve(steam.context, [
238
+ ALICE,
239
+ ]),
240
+ ).toEqual(new Map());
241
+ expect(steam.calls).toEqual([]);
242
+ expect(steam.warnings).toEqual([]);
243
+ });
244
+
245
+ test("answers with nothing and warns once when steam refuses", async () => {
246
+ const steam = fakeSteam({
247
+ answer: () => {
248
+ throw new Error("429 too many requests");
249
+ },
250
+ });
251
+ const avatars = createSteamAvatars({
252
+ steamIdOf: identity,
253
+ });
254
+
255
+ expect(
256
+ await avatars.resolve(steam.context, [
257
+ ALICE,
258
+ ]),
259
+ ).toEqual(new Map());
260
+ expect(steam.warnings.length).toBe(1);
261
+
262
+ await avatars.resolve(steam.context, [
263
+ ALICE,
264
+ ]);
265
+
266
+ expect(steam.warnings.length).toBe(1);
267
+ });
268
+
269
+ test("never writes the api key into the warning it logs", async () => {
270
+ const steam = fakeSteam({
271
+ key: "top-secret-key",
272
+ answer: () => {
273
+ throw new Error("steam said no");
274
+ },
275
+ });
276
+ const avatars = createSteamAvatars({
277
+ steamIdOf: identity,
278
+ });
279
+
280
+ await avatars.resolve(steam.context, [
281
+ ALICE,
282
+ ]);
283
+
284
+ expect(JSON.stringify(steam.warnings)).not.toContain("top-secret-key");
285
+ });
286
+
287
+ test("keeps the roster moving when steam sends a profile with no usable picture", async () => {
288
+ const steam = fakeSteam({
289
+ answer: () => [
290
+ {
291
+ steamid: ALICE,
292
+ avatarfull: "https://example.com/none.png",
293
+ },
294
+ ],
295
+ });
296
+ const avatars = createSteamAvatars({
297
+ steamIdOf: identity,
298
+ });
299
+
300
+ expect(
301
+ await avatars.resolve(steam.context, [
302
+ ALICE,
303
+ ]),
304
+ ).toEqual(new Map());
305
+ });
306
+ });
@@ -0,0 +1,107 @@
1
+ import type { BridgeContext } from "../context";
2
+ import { playerSummaries, steamWebApiReady } from "./webApi";
3
+
4
+ export const STEAM_AVATAR_CACHE_TTL_MS = 21_600_000;
5
+
6
+ const STEAM_AVATAR = /\/([0-9a-f]{40})(?:_[a-z]+)?\.jpg/;
7
+
8
+ export const steamAvatarHashOf = (url: string): string | null => {
9
+ return url.match(STEAM_AVATAR)?.at(1) ?? null;
10
+ };
11
+
12
+ export interface SteamAvatars {
13
+ resolve(context: BridgeContext, ids: string[]): Promise<Map<string, string>>;
14
+ }
15
+
16
+ export interface SteamAvatarsOptions {
17
+ steamIdOf: (id: string) => string | null;
18
+ }
19
+
20
+ interface CachedAvatar {
21
+ hash: string;
22
+ storedAt: number;
23
+ }
24
+
25
+ export const createSteamAvatars = (options: SteamAvatarsOptions): SteamAvatars => {
26
+ const { steamIdOf } = options;
27
+ const cached = new Map<string, CachedAvatar>();
28
+
29
+ let warned = false;
30
+
31
+ const hashOf = (steamId: string, now: number) => {
32
+ const entry = cached.get(steamId);
33
+
34
+ if (!entry) {
35
+ return null;
36
+ }
37
+
38
+ if (now - entry.storedAt >= STEAM_AVATAR_CACHE_TTL_MS) {
39
+ cached.delete(steamId);
40
+
41
+ return null;
42
+ }
43
+
44
+ return entry.hash;
45
+ };
46
+
47
+ const known = (ids: string[], now: number) => {
48
+ const hashes = new Map<string, string>();
49
+
50
+ for (const id of ids) {
51
+ const steamId = steamIdOf(id);
52
+ const hash = steamId === null ? null : hashOf(steamId, now);
53
+
54
+ if (hash !== null) {
55
+ hashes.set(id, hash);
56
+ }
57
+ }
58
+
59
+ return hashes;
60
+ };
61
+
62
+ return {
63
+ async resolve(context, ids) {
64
+ const now = Date.now();
65
+ const missing = new Set(
66
+ ids.flatMap((id) => {
67
+ const steamId = steamIdOf(id);
68
+
69
+ return steamId === null || hashOf(steamId, now) !== null
70
+ ? []
71
+ : [
72
+ steamId,
73
+ ];
74
+ }),
75
+ );
76
+
77
+ if (missing.size === 0 || !steamWebApiReady(context)) {
78
+ return known(ids, now);
79
+ }
80
+
81
+ try {
82
+ for (const summary of await playerSummaries(context, [
83
+ ...missing,
84
+ ])) {
85
+ const hash = steamAvatarHashOf(summary.avatarfull);
86
+
87
+ if (hash !== null) {
88
+ cached.set(summary.steamid, {
89
+ hash,
90
+ storedAt: now,
91
+ });
92
+ }
93
+ }
94
+ } catch (error) {
95
+ if (!warned) {
96
+ warned = true;
97
+
98
+ context.log.warn("the steam web api did not answer for the roster", {
99
+ error: error instanceof Error ? error.message : String(error),
100
+ });
101
+ }
102
+ }
103
+
104
+ return known(ids, now);
105
+ },
106
+ };
107
+ };
@@ -1,3 +1,4 @@
1
+ export * from "./avatars";
1
2
  export * from "./steamApp";
2
3
  export * from "./steamcmd";
3
4
  export * from "./steamcmdLogin";
@@ -10,6 +10,7 @@ import {
10
10
  BridgeKind,
11
11
  BridgeLayout,
12
12
  type BridgeManifest,
13
+ BridgePlace,
13
14
  type BridgeSetupManifest,
14
15
  BridgeSetupStepKind,
15
16
  BridgeUploadMode,
@@ -604,3 +605,81 @@ describe("the setup mirror", () => {
604
605
  expect(bridgeManifestSchema.safeParse(source).success).toBe(false);
605
606
  });
606
607
  });
608
+
609
+ const placedManifest = (): BridgeManifest => ({
610
+ version: "1.0.0",
611
+ panel: {
612
+ tabs: [
613
+ {
614
+ id: "roster",
615
+ title: text("Roster"),
616
+ sections: [
617
+ {
618
+ layout: BridgeLayout.Table,
619
+ id: "online",
620
+ module: "players",
621
+ place: BridgePlace.Players,
622
+ columns: [
623
+ {
624
+ key: "name",
625
+ label: text("Name"),
626
+ },
627
+ ],
628
+ actions: [
629
+ {
630
+ id: "ban",
631
+ label: text("Ban"),
632
+ confirm: BridgeConfirm.Strong,
633
+ offline: true,
634
+ },
635
+ ],
636
+ },
637
+ ],
638
+ },
639
+ {
640
+ id: "settings",
641
+ title: text("Settings"),
642
+ sections: [
643
+ {
644
+ layout: BridgeLayout.Form,
645
+ id: "world",
646
+ title: text("World"),
647
+ help: text("These settings apply the next time the server starts"),
648
+ target: BridgeFormTarget.Variables,
649
+ fields: [
650
+ {
651
+ key: "WORLD_NAME",
652
+ control: BridgeControl.Text,
653
+ label: text("World name"),
654
+ },
655
+ ],
656
+ },
657
+ ],
658
+ },
659
+ ],
660
+ },
661
+ terminal: null,
662
+ setup: null,
663
+ modules: [
664
+ {
665
+ id: "players",
666
+ kind: BridgeKind.Collection,
667
+ requiresRunning: true,
668
+ refreshSeconds: 20,
669
+ pageSize: null,
670
+ dependsOn: [],
671
+ actions: [
672
+ "ban",
673
+ ],
674
+ },
675
+ ],
676
+ events: [],
677
+ });
678
+
679
+ describe("the placement mirror", () => {
680
+ test("carries place, an offline row action and a form help through unchanged", () => {
681
+ const source = placedManifest();
682
+
683
+ expect(bridgeManifestSchema.parse(source)).toEqual(source);
684
+ });
685
+ });
package/src/wire/panel.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  BridgeLayout,
13
13
  type BridgeManifest,
14
14
  type BridgePanel,
15
+ BridgePlace,
15
16
  type BridgeSection,
16
17
  type BridgeSetupManifest,
17
18
  type BridgeSetupStep,
@@ -118,6 +119,7 @@ export const bridgeSectionActionSchema = z.object({
118
119
  confirm: z.enum(BridgeConfirm).optional(),
119
120
  confirmText: panelTextSchema.optional(),
120
121
  download: z.boolean().optional(),
122
+ offline: z.boolean().optional(),
121
123
  fields: z.array(bridgeFieldSchema).max(ACTION_FIELDS_LIMIT).optional(),
122
124
  });
123
125
 
@@ -148,6 +150,7 @@ export const bridgeActionsSectionSchema = z.object({
148
150
  id: zBridgeId,
149
151
  title: panelTextSchema.optional(),
150
152
  help: panelTextSchema.optional(),
153
+ place: z.enum(BridgePlace).optional(),
151
154
  module: zBridgeModuleId,
152
155
  actions: z.array(bridgeSectionActionSchema).min(1).max(SECTION_ACTIONS_LIMIT),
153
156
  visibleWhen: bridgeVisibleWhenSchema.optional(),
@@ -158,6 +161,7 @@ export const bridgeFormSectionSchema = z.object({
158
161
  layout: z.literal(BridgeLayout.Form),
159
162
  id: zBridgeId,
160
163
  title: panelTextSchema.optional(),
164
+ help: panelTextSchema.optional(),
161
165
  target: z.enum(BridgeFormTarget),
162
166
  module: zBridgeModuleId.optional(),
163
167
  preview: bridgePreviewRefSchema.optional(),
@@ -175,6 +179,8 @@ export const bridgeDetailSectionSchema = z.object({
175
179
  layout: z.literal(BridgeLayout.Detail),
176
180
  id: zBridgeId,
177
181
  title: panelTextSchema.optional(),
182
+ help: panelTextSchema.optional(),
183
+ place: z.enum(BridgePlace).optional(),
178
184
  module: zBridgeModuleId,
179
185
  actions: z.array(bridgeSectionActionSchema).max(SECTION_ACTIONS_LIMIT).optional(),
180
186
  reinstall: z.boolean().optional(),
@@ -196,6 +202,8 @@ export const bridgeTableSectionSchema = z.object({
196
202
  layout: z.literal(BridgeLayout.Table),
197
203
  id: zBridgeId,
198
204
  title: panelTextSchema.optional(),
205
+ help: panelTextSchema.optional(),
206
+ place: z.enum(BridgePlace).optional(),
199
207
  module: zBridgeModuleId,
200
208
  restartHint: z.boolean().optional(),
201
209
  columns: z.array(bridgeColumnSchema).min(1).max(8),
@@ -211,6 +219,8 @@ export const bridgeCardsSectionSchema = z.object({
211
219
  layout: z.literal(BridgeLayout.Cards),
212
220
  id: zBridgeId,
213
221
  title: panelTextSchema.optional(),
222
+ help: panelTextSchema.optional(),
223
+ place: z.enum(BridgePlace).optional(),
214
224
  module: zBridgeModuleId,
215
225
  titleKey: zKey,
216
226
  subtitleKey: zKey.optional(),
@@ -224,6 +234,7 @@ export const bridgeCatalogSectionSchema = z.object({
224
234
  layout: z.literal(BridgeLayout.Catalog),
225
235
  id: zBridgeId,
226
236
  title: panelTextSchema.optional(),
237
+ help: panelTextSchema.optional(),
227
238
  module: zBridgeModuleId,
228
239
  restartHint: z.boolean().optional(),
229
240
  reinstall: z.boolean().optional(),