@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,157 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { steamcmdArgv, steamcmdScript, steamcmdUpdateArgs } from "./steamcmd";
3
+ import { SteamcmdLoginKind, steamcmdLoginArgs } from "./steamcmdLogin";
4
+ import { SteamcmdPlatform } from "./steamcmdPlatform";
5
+
6
+ const OPTIONS = {
7
+ appId: "2394010",
8
+ label: "palworld",
9
+ };
10
+
11
+ describe("steamcmdLoginArgs", () => {
12
+ test("logs in anonymously by default", () => {
13
+ expect(steamcmdLoginArgs()).toEqual([
14
+ "anonymous",
15
+ ]);
16
+ });
17
+
18
+ test("passes the account credentials through", () => {
19
+ expect(
20
+ steamcmdLoginArgs({
21
+ kind: SteamcmdLoginKind.Account,
22
+ username: "serverk",
23
+ password: "hunter2",
24
+ }),
25
+ ).toEqual([
26
+ "serverk",
27
+ "hunter2",
28
+ ]);
29
+ });
30
+ });
31
+
32
+ describe("steamcmdScript", () => {
33
+ test("lives inside the copied steamcmd directory", () => {
34
+ expect(steamcmdScript()).toBe(".steamcmd/steamcmd.sh");
35
+ expect(steamcmdScript(".sc")).toBe(".sc/steamcmd.sh");
36
+ });
37
+ });
38
+
39
+ describe("steamcmdArgv", () => {
40
+ test("forces the install directory and logs in before the command", () => {
41
+ expect(
42
+ steamcmdArgv(OPTIONS, "/home/container", [
43
+ "+app_update",
44
+ "2394010",
45
+ ]),
46
+ ).toEqual([
47
+ "./.steamcmd/steamcmd.sh",
48
+ "+force_install_dir",
49
+ "/home/container",
50
+ "+login",
51
+ "anonymous",
52
+ "+app_update",
53
+ "2394010",
54
+ "+quit",
55
+ ]);
56
+ });
57
+
58
+ test("forces the windows platform before the login", () => {
59
+ const argv = steamcmdArgv(
60
+ {
61
+ ...OPTIONS,
62
+ platform: SteamcmdPlatform.Windows,
63
+ },
64
+ "/home/container",
65
+ [],
66
+ );
67
+
68
+ expect(argv.at(1)).toBe("+@sSteamCmdForcePlatformType");
69
+ expect(argv.at(2)).toBe("windows");
70
+ expect(argv.indexOf("+@sSteamCmdForcePlatformType")).toBeLessThan(argv.indexOf("+login"));
71
+ });
72
+
73
+ test("leaves the platform alone on linux", () => {
74
+ expect(
75
+ steamcmdArgv(
76
+ {
77
+ ...OPTIONS,
78
+ platform: SteamcmdPlatform.Linux,
79
+ },
80
+ "/home/container",
81
+ [],
82
+ ),
83
+ ).not.toContain("+@sSteamCmdForcePlatformType");
84
+ });
85
+ });
86
+
87
+ describe("steamcmdUpdateArgs", () => {
88
+ test("updates without validating by default", () => {
89
+ expect(
90
+ steamcmdUpdateArgs("258550", {
91
+ validate: false,
92
+ }),
93
+ ).toEqual([
94
+ "+app_update",
95
+ "258550",
96
+ ]);
97
+ });
98
+
99
+ test("adds validate last of the update flags", () => {
100
+ expect(
101
+ steamcmdUpdateArgs("258550", {
102
+ validate: true,
103
+ }),
104
+ ).toEqual([
105
+ "+app_update",
106
+ "258550",
107
+ "validate",
108
+ ]);
109
+ });
110
+
111
+ test("passes a branch and its password", () => {
112
+ expect(
113
+ steamcmdUpdateArgs("90", {
114
+ validate: true,
115
+ branch: "steam_legacy",
116
+ branchPassword: "shh",
117
+ }),
118
+ ).toEqual([
119
+ "+app_update",
120
+ "90",
121
+ "-beta",
122
+ "steam_legacy",
123
+ "-betapassword",
124
+ "shh",
125
+ "validate",
126
+ ]);
127
+ });
128
+
129
+ test("ignores a branch password without a branch", () => {
130
+ expect(
131
+ steamcmdUpdateArgs("90", {
132
+ validate: false,
133
+ branchPassword: "shh",
134
+ }),
135
+ ).toEqual([
136
+ "+app_update",
137
+ "90",
138
+ ]);
139
+ });
140
+
141
+ test("appends the extra arguments", () => {
142
+ expect(
143
+ steamcmdUpdateArgs("376030", {
144
+ validate: false,
145
+ extraArgs: [
146
+ "-language",
147
+ "english",
148
+ ],
149
+ }),
150
+ ).toEqual([
151
+ "+app_update",
152
+ "376030",
153
+ "-language",
154
+ "english",
155
+ ]);
156
+ });
157
+ });
@@ -0,0 +1,273 @@
1
+ import type { BridgeContext } from "../context";
2
+ import { execDetail } from "../utils";
3
+ import { installedBuildId } from "./steamApp";
4
+ import { type SteamcmdLogin, SteamcmdLoginKind, steamcmdLoginArgs } from "./steamcmdLogin";
5
+ import { steamcmdNarrator } from "./steamcmdNarrator";
6
+ import { SteamcmdPlatform } from "./steamcmdPlatform";
7
+
8
+ export const STEAMCMD_IMAGE_DIRECTORY = "/opt/steamcmd";
9
+
10
+ export const STEAMCMD_DIRECTORY = ".steamcmd";
11
+
12
+ export const STEAM_DIRECTORY = ".steam";
13
+
14
+ const STEAM_CLIENT_DIRECTORY = `${STEAM_DIRECTORY}/sdk64`;
15
+
16
+ const SENTRY_FILE = ".sentry.b64";
17
+
18
+ const UPDATE_TIMEOUT_MS = 3_600_000;
19
+
20
+ const BOOTSTRAP_TIMEOUT_MS = 300_000;
21
+
22
+ const SENTRY_TIMEOUT_MS = 60_000;
23
+
24
+ const ATTEMPTS = 3;
25
+
26
+ const RETRY_DELAY_MS = 5000;
27
+
28
+ export interface SteamcmdOptions {
29
+ appId: string;
30
+ label: string;
31
+ imageDir?: string;
32
+ directory?: string;
33
+ platform?: SteamcmdPlatform;
34
+ login?: SteamcmdLogin;
35
+ }
36
+
37
+ export interface SteamcmdRunOptions {
38
+ timeoutMs?: number;
39
+ attempts?: number;
40
+ narrate?: boolean;
41
+ }
42
+
43
+ export interface SteamcmdUpdateOptions {
44
+ validate: boolean;
45
+ branch?: string;
46
+ branchPassword?: string;
47
+ extraArgs?: string[];
48
+ }
49
+
50
+ export interface Steamcmd {
51
+ directory: string;
52
+ script: string;
53
+ prepare(): Promise<void>;
54
+ run(args: string[], options?: SteamcmdRunOptions): Promise<void>;
55
+ update(options: SteamcmdUpdateOptions): Promise<void>;
56
+ buildId(): Promise<string | null>;
57
+ linkSteamClient(): Promise<void>;
58
+ }
59
+
60
+ export const steamcmdScript = (directory: string = STEAMCMD_DIRECTORY) => {
61
+ return `${directory}/steamcmd.sh`;
62
+ };
63
+
64
+ export const steamcmdArgv = (options: SteamcmdOptions, root: string, args: string[]) => {
65
+ const argv = [
66
+ `./${steamcmdScript(options.directory)}`,
67
+ ];
68
+
69
+ if (options.platform === SteamcmdPlatform.Windows) {
70
+ argv.push("+@sSteamCmdForcePlatformType", "windows");
71
+ }
72
+
73
+ argv.push("+force_install_dir", root, "+login", ...steamcmdLoginArgs(options.login), ...args, "+quit");
74
+
75
+ return argv;
76
+ };
77
+
78
+ export const steamcmdUpdateArgs = (appId: string, update: SteamcmdUpdateOptions) => {
79
+ const args = [
80
+ "+app_update",
81
+ appId,
82
+ ];
83
+
84
+ if (update.branch) {
85
+ args.push("-beta", update.branch);
86
+
87
+ if (update.branchPassword) {
88
+ args.push("-betapassword", update.branchPassword);
89
+ }
90
+ }
91
+
92
+ if (update.validate) {
93
+ args.push("validate");
94
+ }
95
+
96
+ if (update.extraArgs) {
97
+ args.push(...update.extraArgs);
98
+ }
99
+
100
+ return args;
101
+ };
102
+
103
+ const installRoot = async (context: BridgeContext) => {
104
+ const result = await context.exec([
105
+ "pwd",
106
+ ]);
107
+
108
+ if (result.code !== 0) {
109
+ throw new Error(`the server directory could not be resolved — ${execDetail(result)}`);
110
+ }
111
+
112
+ return result.stdout.trim();
113
+ };
114
+
115
+ export const createSteamcmd = (context: BridgeContext, options: SteamcmdOptions): Steamcmd => {
116
+ const directory = options.directory ?? STEAMCMD_DIRECTORY;
117
+ const imageDir = options.imageDir ?? STEAMCMD_IMAGE_DIRECTORY;
118
+ const script = steamcmdScript(directory);
119
+ const login = options.login;
120
+
121
+ const applySentry = async () => {
122
+ if (login?.kind !== SteamcmdLoginKind.Account || !login.sentry) {
123
+ return;
124
+ }
125
+
126
+ context.log("restoring the steam sentry");
127
+
128
+ const encoded = `${directory}/${SENTRY_FILE}`;
129
+
130
+ await context.files.write(encoded, login.sentry);
131
+
132
+ const result = await context.exec(
133
+ [
134
+ "sh",
135
+ "-c",
136
+ 'base64 -d < "$1" | tar -x -C "$2"',
137
+ "steamcmd-sentry",
138
+ encoded,
139
+ directory,
140
+ ],
141
+ {
142
+ timeoutMs: SENTRY_TIMEOUT_MS,
143
+ },
144
+ );
145
+
146
+ await context.files.remove(encoded);
147
+
148
+ if (result.code !== 0) {
149
+ throw new Error(`the steam sentry could not be restored — ${execDetail(result)}`);
150
+ }
151
+ };
152
+
153
+ const run: Steamcmd["run"] = async (args, runOptions = {}) => {
154
+ const argv = steamcmdArgv(
155
+ {
156
+ ...options,
157
+ directory,
158
+ },
159
+ await installRoot(context),
160
+ args,
161
+ );
162
+
163
+ const attempts = runOptions.attempts ?? ATTEMPTS;
164
+
165
+ let failure = "";
166
+
167
+ for (let attempt = 1; attempt <= attempts; attempt++) {
168
+ const result = await context.exec(argv, {
169
+ timeoutMs: runOptions.timeoutMs ?? UPDATE_TIMEOUT_MS,
170
+ ...(runOptions.narrate === false
171
+ ? {}
172
+ : {
173
+ onOutput: steamcmdNarrator(context.log, options.label),
174
+ }),
175
+ });
176
+
177
+ if (result.code === 0) {
178
+ return;
179
+ }
180
+
181
+ failure = `steamcmd exited with code ${result.code} — ${execDetail(result)}`;
182
+
183
+ if (attempt < attempts) {
184
+ context.log("steamcmd did not finish, trying again", {
185
+ attempt,
186
+ of: attempts,
187
+ code: result.code,
188
+ });
189
+
190
+ await Bun.sleep(RETRY_DELAY_MS);
191
+ }
192
+ }
193
+
194
+ throw new Error(failure);
195
+ };
196
+
197
+ return {
198
+ directory,
199
+ script,
200
+
201
+ async prepare() {
202
+ if (await context.files.exists(script)) {
203
+ return;
204
+ }
205
+
206
+ context.log("preparing steamcmd");
207
+
208
+ await context.files.ensure(directory);
209
+
210
+ const copied = await context.exec([
211
+ "cp",
212
+ "-a",
213
+ `${imageDir}/.`,
214
+ directory,
215
+ ]);
216
+
217
+ if (copied.code !== 0) {
218
+ throw new Error(`steamcmd could not be prepared — ${execDetail(copied)}`);
219
+ }
220
+
221
+ await applySentry();
222
+
223
+ context.log("warming up steamcmd");
224
+
225
+ const bootstrap = await context.exec(
226
+ [
227
+ `./${script}`,
228
+ "+login",
229
+ ...steamcmdLoginArgs(login),
230
+ "+app_info_update",
231
+ "1",
232
+ "+app_info_print",
233
+ options.appId,
234
+ "+quit",
235
+ ],
236
+ {
237
+ timeoutMs: BOOTSTRAP_TIMEOUT_MS,
238
+ },
239
+ );
240
+
241
+ if (bootstrap.code !== 0) {
242
+ context.log("steamcmd warm-up did not finish cleanly, continuing", {
243
+ code: bootstrap.code,
244
+ });
245
+ }
246
+ },
247
+
248
+ run,
249
+
250
+ async update(update) {
251
+ await run(steamcmdUpdateArgs(options.appId, update));
252
+ },
253
+
254
+ async buildId() {
255
+ return await installedBuildId(context, options.appId);
256
+ },
257
+
258
+ async linkSteamClient() {
259
+ await context.files.ensure(STEAM_CLIENT_DIRECTORY);
260
+
261
+ const result = await context.exec([
262
+ "cp",
263
+ "-f",
264
+ `${directory}/linux64/steamclient.so`,
265
+ `${STEAM_CLIENT_DIRECTORY}/steamclient.so`,
266
+ ]);
267
+
268
+ if (result.code !== 0) {
269
+ throw new Error(`steamclient.so could not be linked — ${execDetail(result)}`);
270
+ }
271
+ },
272
+ };
273
+ };
@@ -0,0 +1,34 @@
1
+ export enum SteamcmdLoginKind {
2
+ Account = "account",
3
+ Anonymous = "anonymous",
4
+ }
5
+
6
+ export interface SteamcmdAnonymousLogin {
7
+ kind: SteamcmdLoginKind.Anonymous;
8
+ }
9
+
10
+ export interface SteamcmdAccountLogin {
11
+ kind: SteamcmdLoginKind.Account;
12
+ username: string;
13
+ password: string;
14
+ sentry?: string;
15
+ }
16
+
17
+ export type SteamcmdLogin = SteamcmdAnonymousLogin | SteamcmdAccountLogin;
18
+
19
+ export const ANONYMOUS_LOGIN: SteamcmdAnonymousLogin = {
20
+ kind: SteamcmdLoginKind.Anonymous,
21
+ };
22
+
23
+ export const steamcmdLoginArgs = (login: SteamcmdLogin = ANONYMOUS_LOGIN) => {
24
+ if (login.kind === SteamcmdLoginKind.Anonymous) {
25
+ return [
26
+ "anonymous",
27
+ ];
28
+ }
29
+
30
+ return [
31
+ login.username,
32
+ login.password,
33
+ ];
34
+ };
@@ -0,0 +1,90 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { BridgeLog } from "../context";
3
+ import type { BridgeValues } from "../core";
4
+ import { steamcmdNarrator } from "./steamcmdNarrator";
5
+
6
+ const recorder = () => {
7
+ const lines: string[] = [];
8
+ const fields: (BridgeValues | undefined)[] = [];
9
+
10
+ const log = ((message: string, values?: BridgeValues) => {
11
+ lines.push(message);
12
+ fields.push(values);
13
+ }) as BridgeLog;
14
+
15
+ return {
16
+ log,
17
+ lines,
18
+ fields,
19
+ };
20
+ };
21
+
22
+ describe("steamcmdNarrator", () => {
23
+ test("reports download progress in ten percent steps", () => {
24
+ const { log, lines, fields } = recorder();
25
+ const narrate = steamcmdNarrator(log, "palworld");
26
+
27
+ for (const percent of [
28
+ 2,
29
+ 9,
30
+ 11,
31
+ 12,
32
+ 25,
33
+ 99,
34
+ ]) {
35
+ narrate(`Update state (0x61) downloading, progress: ${percent}.44 (1 / 2)`);
36
+ }
37
+
38
+ expect(lines).toEqual([
39
+ "downloading palworld",
40
+ "downloading palworld",
41
+ "downloading palworld",
42
+ "downloading palworld",
43
+ ]);
44
+ expect(fields).toEqual([
45
+ {
46
+ percent: 0,
47
+ },
48
+ {
49
+ percent: 10,
50
+ },
51
+ {
52
+ percent: 20,
53
+ },
54
+ {
55
+ percent: 90,
56
+ },
57
+ ]);
58
+ });
59
+
60
+ test("starts counting again when the phase changes", () => {
61
+ const { log, fields } = recorder();
62
+ const narrate = steamcmdNarrator(log, "rust");
63
+
64
+ narrate("Update state (0x61) downloading, progress: 80.00 (1 / 2)");
65
+ narrate("Update state (0x81) verifying, progress: 10.00 (1 / 2)");
66
+
67
+ expect(fields).toEqual([
68
+ {
69
+ percent: 80,
70
+ },
71
+ {
72
+ percent: 10,
73
+ },
74
+ ]);
75
+ });
76
+
77
+ test("passes notable lines through with the ansi stripped", () => {
78
+ const { log, lines } = recorder();
79
+ const narrate = steamcmdNarrator(log, "rust");
80
+
81
+ narrate(`${String.fromCharCode(27)}[1mSuccess! App '258550' fully installed.${String.fromCharCode(27)}[0m`);
82
+ narrate("Logging in user 'serverk' to Steam Public...");
83
+ narrate("some chatter nobody needs");
84
+
85
+ expect(lines).toEqual([
86
+ "Success! App '258550' fully installed.",
87
+ "Logging in user 'serverk' to Steam Public...",
88
+ ]);
89
+ });
90
+ });
@@ -0,0 +1,43 @@
1
+ import type { BridgeLog } from "../context";
2
+ import { stripAnsi } from "../utils";
3
+
4
+ const PROGRESS = /Update state \(0x[0-9a-f]+\)\s*(?<phase>[^,]+),\s*progress:\s*(?<percent>\d+)\.\d+/i;
5
+
6
+ const PROGRESS_STEP = 10;
7
+
8
+ const NOTABLE = /^(Error|Warning|Failed|Success|Logging in|Connecting|Update state \(0x\d+\) (?!downloading))/i;
9
+
10
+ export const steamcmdNarrator = (log: BridgeLog, label: string) => {
11
+ let phase = "";
12
+ let reported = -1;
13
+
14
+ return (raw: string) => {
15
+ const line = stripAnsi(raw).trim();
16
+ const progress = line.match(PROGRESS);
17
+
18
+ if (progress?.groups) {
19
+ const current = progress.groups.phase?.trim() ?? "";
20
+ const percent = Number(progress.groups.percent);
21
+ const step = Math.floor(percent / PROGRESS_STEP) * PROGRESS_STEP;
22
+
23
+ if (current !== phase) {
24
+ phase = current;
25
+ reported = -1;
26
+ }
27
+
28
+ if (step > reported) {
29
+ reported = step;
30
+
31
+ log(`${phase} ${label}`, {
32
+ percent: step,
33
+ });
34
+ }
35
+
36
+ return;
37
+ }
38
+
39
+ if (NOTABLE.test(line)) {
40
+ log(line);
41
+ }
42
+ };
43
+ };
@@ -0,0 +1,4 @@
1
+ export enum SteamcmdPlatform {
2
+ Linux = "linux",
3
+ Windows = "windows",
4
+ }
@@ -0,0 +1,70 @@
1
+ import type { BridgeContext } from "../context";
2
+ import { BridgeSecretError } from "../core";
3
+
4
+ export const STEAM_WEB_API = "https://api.steampowered.com";
5
+
6
+ export const STEAM_WEB_API_SECRET = "STEAM_WEB_API_KEY";
7
+
8
+ const SUMMARY_BATCH = 100;
9
+
10
+ const SUMMARY_CACHE_SECONDS = 600;
11
+
12
+ export interface SteamPlayerSummary {
13
+ steamid: string;
14
+ personaname: string;
15
+ profileurl: string;
16
+ avatar: string;
17
+ avatarmedium: string;
18
+ avatarfull: string;
19
+ }
20
+
21
+ interface PlayerSummaryResponse {
22
+ response?: {
23
+ players?: SteamPlayerSummary[];
24
+ };
25
+ }
26
+
27
+ export const steamWebApiReady = (context: BridgeContext) => {
28
+ return context.secret(STEAM_WEB_API_SECRET) !== null;
29
+ };
30
+
31
+ const webApiKey = (context: BridgeContext) => {
32
+ const key = context.secret(STEAM_WEB_API_SECRET);
33
+
34
+ if (!key) {
35
+ throw new BridgeSecretError(STEAM_WEB_API_SECRET, "the steam web api needs a key before it can be asked");
36
+ }
37
+
38
+ return key;
39
+ };
40
+
41
+ export const playerSummaries = async (context: BridgeContext, ids: string[]): Promise<SteamPlayerSummary[]> => {
42
+ const wanted = [
43
+ ...new Set(ids.filter((id) => id.length > 0)),
44
+ ];
45
+
46
+ if (wanted.length === 0) {
47
+ return [];
48
+ }
49
+
50
+ const key = webApiKey(context);
51
+ const summaries: SteamPlayerSummary[] = [];
52
+
53
+ for (let cursor = 0; cursor < wanted.length; cursor += SUMMARY_BATCH) {
54
+ const query = new URLSearchParams({
55
+ key,
56
+ steamids: wanted.slice(cursor, cursor + SUMMARY_BATCH).join(","),
57
+ });
58
+
59
+ const page = await context.net.json<PlayerSummaryResponse>(
60
+ `${STEAM_WEB_API}/ISteamUser/GetPlayerSummaries/v2/?${query.toString()}`,
61
+ {
62
+ cacheSeconds: SUMMARY_CACHE_SECONDS,
63
+ },
64
+ );
65
+
66
+ summaries.push(...(page.response?.players ?? []));
67
+ }
68
+
69
+ return summaries;
70
+ };
@@ -0,0 +1,45 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { workshopContentPath, workshopDownloadArgs, workshopFormBody } from "./workshop";
3
+
4
+ describe("workshopFormBody", () => {
5
+ test("writes the count and the indexed ids", () => {
6
+ expect(
7
+ workshopFormBody("itemcount", [
8
+ "450814997",
9
+ "1234",
10
+ ]),
11
+ ).toBe("itemcount=2&publishedfileids%5B0%5D=450814997&publishedfileids%5B1%5D=1234");
12
+ });
13
+
14
+ test("names the count field the collection api expects", () => {
15
+ expect(
16
+ workshopFormBody("collectioncount", [
17
+ "1",
18
+ ]),
19
+ ).toBe("collectioncount=1&publishedfileids%5B0%5D=1");
20
+ });
21
+ });
22
+
23
+ describe("workshopDownloadArgs", () => {
24
+ test("asks steamcmd for every item", () => {
25
+ expect(
26
+ workshopDownloadArgs("440900", [
27
+ "1",
28
+ "2",
29
+ ]),
30
+ ).toEqual([
31
+ "+workshop_download_item",
32
+ "440900",
33
+ "1",
34
+ "+workshop_download_item",
35
+ "440900",
36
+ "2",
37
+ ]);
38
+ });
39
+ });
40
+
41
+ describe("workshopContentPath", () => {
42
+ test("points at where steamcmd unpacks an item", () => {
43
+ expect(workshopContentPath("440900", "12345")).toBe("steamapps/workshop/content/440900/12345");
44
+ });
45
+ });