@serverkgg/bridge 0.0.14 → 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.
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { tmpdir } from "node:os";
3
3
  import { BridgeAction, BridgeKind, type BridgeRow, BridgeSetupStepKind, type BridgeValues } from "../core";
4
4
  import type { BridgeDriver } from "../modules";
5
+ import { BridgeLayout } from "../panel";
5
6
  import { type BridgeConfig, BridgeConfirm, BridgeEventName } from "../protocol";
6
7
  import { createContext } from "./bridgeContext";
7
8
  import { createEventRing } from "./bridgeEvents";
@@ -296,6 +297,101 @@ describe("collection row actions", () => {
296
297
  });
297
298
  });
298
299
 
300
+ describe("offline row actions", () => {
301
+ const rosterDriver = (
302
+ offline: boolean,
303
+ list: () => Promise<BridgeRow[]>,
304
+ ban: (row: BridgeRow) => void,
305
+ ): BridgeDriver => ({
306
+ ...baseDriver(),
307
+ panel: {
308
+ tabs: [
309
+ {
310
+ id: "players",
311
+ title: {
312
+ ar: "اللاعبين",
313
+ en: "Players",
314
+ },
315
+ sections: [
316
+ {
317
+ layout: BridgeLayout.Table,
318
+ id: "online",
319
+ module: "players",
320
+ columns: [
321
+ {
322
+ key: "name",
323
+ label: {
324
+ ar: "الاسم",
325
+ en: "Name",
326
+ },
327
+ },
328
+ ],
329
+ actions: [
330
+ {
331
+ id: "ban",
332
+ label: {
333
+ ar: "حظر",
334
+ en: "Ban",
335
+ },
336
+ offline,
337
+ },
338
+ ],
339
+ },
340
+ ],
341
+ },
342
+ ],
343
+ },
344
+ modules: {
345
+ players: {
346
+ kind: BridgeKind.Collection,
347
+ list,
348
+ actions: {
349
+ ban: async (_context, row) => {
350
+ ban(row);
351
+ },
352
+ },
353
+ },
354
+ },
355
+ });
356
+
357
+ test("hands an offline action the id alone when the row is not listed", async () => {
358
+ const seen: BridgeRow[] = [];
359
+ const registry = registryFor(
360
+ rosterDriver(
361
+ true,
362
+ async () => [],
363
+ (row) => seen.push(row),
364
+ ),
365
+ );
366
+
367
+ await registry.invoke("players", "ban", {
368
+ id: "steam_1",
369
+ });
370
+
371
+ expect(seen).toEqual([
372
+ {
373
+ id: "steam_1",
374
+ },
375
+ ]);
376
+ });
377
+
378
+ test("still refuses an unlisted row for an action that is not offline", async () => {
379
+ const registry = registryFor(
380
+ rosterDriver(
381
+ false,
382
+ async () => [],
383
+ () => undefined,
384
+ ),
385
+ );
386
+
387
+ await expect(
388
+ registry.invoke("players", "ban", {
389
+ id: "steam_1",
390
+ }),
391
+ ).rejects.toThrow('module "players" has no row "steam_1"');
392
+ });
393
+ });
394
+
299
395
  describe("preview modules", () => {
300
396
  test("lists the preview action in the manifest", () => {
301
397
  const registry = registryFor({
@@ -1,6 +1,7 @@
1
1
  import type { BridgeContext } from "../context";
2
2
  import { BridgeAction, BridgeKind, type BridgeRow, type BridgeValues, isReadAction } from "../core";
3
3
  import type { BridgeDriver, BridgePanelModule, BridgeSetupModule, BridgeTerminalModule } from "../modules";
4
+ import { BridgeLayout } from "../panel";
4
5
  import {
5
6
  BRIDGE_VERSION,
6
7
  type BridgeManifest,
@@ -148,6 +149,29 @@ const asPage = (args: BridgeValues) => {
148
149
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
149
150
  };
150
151
 
152
+ const offlineActionsOf = (driver: BridgeDriver): Map<string, Set<string>> => {
153
+ const offline = new Map<string, Set<string>>();
154
+
155
+ for (const tab of driver.panel?.tabs ?? []) {
156
+ for (const section of tab.sections) {
157
+ if (section.layout !== BridgeLayout.Table && section.layout !== BridgeLayout.Cards) {
158
+ continue;
159
+ }
160
+
161
+ for (const action of section.actions ?? []) {
162
+ if (action.offline === true) {
163
+ const actions = offline.get(section.module) ?? new Set<string>();
164
+
165
+ actions.add(action.id);
166
+ offline.set(section.module, actions);
167
+ }
168
+ }
169
+ }
170
+ }
171
+
172
+ return offline;
173
+ };
174
+
151
175
  export const createRegistry = (
152
176
  driver: BridgeDriver,
153
177
  context: BridgeContext,
@@ -157,6 +181,7 @@ export const createRegistry = (
157
181
  const cache = new Map<string, CacheEntry>();
158
182
  const inflight = new Map<string, Promise<unknown>>();
159
183
  const modules = driver.modules ?? {};
184
+ const offlineActions = offlineActionsOf(driver);
160
185
 
161
186
  for (const [id, module] of Object.entries(modules)) {
162
187
  if (module.kind !== BridgeKind.Actions && module.kind !== BridgeKind.Detail) {
@@ -297,7 +322,14 @@ export const createRegistry = (
297
322
  if (rowAction) {
298
323
  const rows = await module.list(context);
299
324
  const id = asString(args, "id");
300
- const row = rows.find((entry) => entry.id === id);
325
+ const listed = rows.find((entry) => entry.id === id);
326
+ const row =
327
+ listed
328
+ ?? (offlineActions.get(name)?.has(action)
329
+ ? {
330
+ id,
331
+ }
332
+ : undefined);
301
333
 
302
334
  if (!row) {
303
335
  throw new BridgeDriverError(`module "${name}" has no row "${id}"`);
@@ -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
+ });