@serverkgg/bridge 0.0.12 → 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.
- package/package.json +9 -1
- package/src/catalogs/curseforge.test.ts +580 -0
- package/src/catalogs/curseforge.ts +461 -0
- package/src/catalogs/index.ts +2 -0
- package/src/catalogs/thunderstore.test.ts +94 -0
- package/src/catalogs/thunderstore.ts +239 -0
- package/src/codecs/index.ts +3 -0
- package/src/codecs/ini.test.ts +152 -0
- package/src/codecs/ini.ts +158 -0
- package/src/codecs/sourceCfg.test.ts +100 -0
- package/src/codecs/sourceCfg.ts +111 -0
- package/src/codecs/xmlProperties.test.ts +104 -0
- package/src/codecs/xmlProperties.ts +133 -0
- package/src/context/bridgeContext.ts +20 -0
- package/src/core/bridgeRconError.ts +19 -0
- package/src/core/index.ts +1 -0
- package/src/install/index.ts +1 -0
- package/src/install/installStamp.test.ts +125 -0
- package/src/install/installStamp.ts +40 -0
- package/src/main.ts +1 -0
- package/src/manifest/index.ts +1 -0
- package/src/manifest/manifest.test.ts +427 -2
- package/src/manifest/manifest.ts +141 -13
- package/src/manifest/primitives.ts +6 -0
- package/src/manifest/runtime.ts +89 -0
- package/src/net/index.ts +2 -0
- package/src/net/loopback.test.ts +152 -0
- package/src/net/loopback.ts +109 -0
- package/src/net/loopbackMethod.ts +7 -0
- package/src/presence/index.ts +1 -0
- package/src/presence/rosterSync.test.ts +179 -0
- package/src/presence/rosterSync.ts +57 -0
- package/src/protocol/bridgeEvents.ts +4 -0
- package/src/rcon/index.ts +3 -0
- package/src/rcon/sourceRcon.test.ts +308 -0
- package/src/rcon/sourceRcon.ts +276 -0
- package/src/rcon/sourceRconFrame.test.ts +152 -0
- package/src/rcon/sourceRconFrame.ts +99 -0
- package/src/runtime/bridgeCodec.ts +45 -2
- package/src/runtime/bridgeContext.ts +13 -6
- package/src/runtime/bridgeDriver.test.ts +1 -0
- package/src/runtime/bridgeLiveConfig.test.ts +149 -0
- package/src/runtime/bridgeLiveConfig.ts +63 -0
- package/src/runtime/bridgeRcon.ts +15 -0
- package/src/runtime/bridgeRegistry.ts +1 -0
- package/src/steam/index.ts +7 -0
- package/src/steam/steamApp.test.ts +78 -0
- package/src/steam/steamApp.ts +35 -0
- package/src/steam/steamcmd.test.ts +157 -0
- package/src/steam/steamcmd.ts +273 -0
- package/src/steam/steamcmdLogin.ts +34 -0
- package/src/steam/steamcmdNarrator.test.ts +90 -0
- package/src/steam/steamcmdNarrator.ts +43 -0
- package/src/steam/steamcmdPlatform.ts +4 -0
- package/src/steam/webApi.ts +70 -0
- package/src/steam/workshop.test.ts +45 -0
- package/src/steam/workshop.ts +148 -0
- package/src/utils/execDetail.ts +16 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/token.test.ts +50 -0
- package/src/utils/token.ts +35 -0
- package/src/wine/index.ts +3 -0
- package/src/wine/protonCommand.test.ts +67 -0
- package/src/wine/protonCommand.ts +49 -0
- package/src/wine/wineCommand.test.ts +92 -0
- package/src/wine/wineCommand.ts +54 -0
- package/src/wine/winePrefix.test.ts +111 -0
- package/src/wine/winePrefix.ts +88 -0
- package/src/runtime/bridgeSecrets.test.ts +0 -121
- package/src/runtime/bridgeSecrets.ts +0 -43
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import type { BridgeContext } from "../context";
|
|
2
|
+
import { BridgeFailureCode, BridgeFailureError, BridgeNetError, BridgeSecretError } from "../core";
|
|
3
|
+
import { BridgeHttpMethod } from "../protocol";
|
|
4
|
+
|
|
5
|
+
export const CURSEFORGE_API = "https://api.curseforge.com/v1";
|
|
6
|
+
|
|
7
|
+
export const CURSEFORGE_DOWNLOAD = "https://edge.forgecdn.net";
|
|
8
|
+
|
|
9
|
+
export const CURSEFORGE_CDN_SUFFIX = ".forgecdn.net";
|
|
10
|
+
|
|
11
|
+
export const CURSEFORGE_SECRET = "CURSEFORGE_API_KEY";
|
|
12
|
+
|
|
13
|
+
export const CURSEFORGE_SEARCH_CEILING = 10_000;
|
|
14
|
+
|
|
15
|
+
export const CURSEFORGE_SERVER_TAG = "server";
|
|
16
|
+
|
|
17
|
+
export const CURSEFORGE_CLIENT_TAG = "client";
|
|
18
|
+
|
|
19
|
+
const SEARCH_CACHE_SECONDS = 60;
|
|
20
|
+
|
|
21
|
+
const PROJECT_CACHE_SECONDS = 600;
|
|
22
|
+
|
|
23
|
+
const CATEGORY_CACHE_SECONDS = 86_400;
|
|
24
|
+
|
|
25
|
+
const BATCH = 100;
|
|
26
|
+
|
|
27
|
+
const SPLIT_ID_LENGTH = 7;
|
|
28
|
+
|
|
29
|
+
const SPLIT_ID_FOLDER = 4;
|
|
30
|
+
|
|
31
|
+
const LEGACY_ID_DIVISOR = 1000;
|
|
32
|
+
|
|
33
|
+
const REJECTED_STATUSES = [
|
|
34
|
+
401,
|
|
35
|
+
403,
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const THROTTLED_STATUSES = [
|
|
39
|
+
429,
|
|
40
|
+
503,
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export enum CurseforgeHash {
|
|
44
|
+
Md5 = 2,
|
|
45
|
+
Sha1 = 1,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export enum CurseforgeReleaseType {
|
|
49
|
+
Alpha = 3,
|
|
50
|
+
Beta = 2,
|
|
51
|
+
Release = 1,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export enum CurseforgeDependency {
|
|
55
|
+
EmbeddedLibrary = 1,
|
|
56
|
+
Include = 6,
|
|
57
|
+
Incompatible = 5,
|
|
58
|
+
Optional = 2,
|
|
59
|
+
Required = 3,
|
|
60
|
+
Tool = 4,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export enum CurseforgeSort {
|
|
64
|
+
Author = "5",
|
|
65
|
+
Featured = "1",
|
|
66
|
+
LastUpdated = "3",
|
|
67
|
+
Name = "4",
|
|
68
|
+
Popularity = "2",
|
|
69
|
+
TotalDownloads = "6",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export enum CurseforgeSortOrder {
|
|
73
|
+
Asc = "asc",
|
|
74
|
+
Desc = "desc",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface CurseforgeAuthor {
|
|
78
|
+
name: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface CurseforgeCategory {
|
|
82
|
+
id: number;
|
|
83
|
+
name: string;
|
|
84
|
+
classId?: number | null;
|
|
85
|
+
isClass?: boolean | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface CurseforgeMod {
|
|
89
|
+
id: number;
|
|
90
|
+
classId: number | null;
|
|
91
|
+
name: string;
|
|
92
|
+
slug: string;
|
|
93
|
+
summary: string;
|
|
94
|
+
downloadCount: number;
|
|
95
|
+
authors: CurseforgeAuthor[];
|
|
96
|
+
categories: CurseforgeCategory[];
|
|
97
|
+
dateModified: string | null;
|
|
98
|
+
allowModDistribution: boolean | null;
|
|
99
|
+
logo: {
|
|
100
|
+
thumbnailUrl: string | null;
|
|
101
|
+
} | null;
|
|
102
|
+
links: {
|
|
103
|
+
websiteUrl: string | null;
|
|
104
|
+
} | null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface CurseforgeHashEntry {
|
|
108
|
+
value: string;
|
|
109
|
+
algo: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface CurseforgeDependencyEntry {
|
|
113
|
+
modId: number;
|
|
114
|
+
relationType: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface CurseforgeFile {
|
|
118
|
+
id: number;
|
|
119
|
+
modId: number;
|
|
120
|
+
fileName: string;
|
|
121
|
+
displayName: string;
|
|
122
|
+
downloadUrl: string | null;
|
|
123
|
+
fileLength: number;
|
|
124
|
+
fileDate: string | null;
|
|
125
|
+
isAvailable: boolean;
|
|
126
|
+
isServerPack?: boolean;
|
|
127
|
+
serverPackFileId?: number | null;
|
|
128
|
+
parentProjectFileId?: number | null;
|
|
129
|
+
releaseType: number;
|
|
130
|
+
gameVersions: string[];
|
|
131
|
+
hashes: CurseforgeHashEntry[];
|
|
132
|
+
dependencies: CurseforgeDependencyEntry[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface CurseforgePagination {
|
|
136
|
+
index: number;
|
|
137
|
+
pageSize: number;
|
|
138
|
+
resultCount: number;
|
|
139
|
+
totalCount: number;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface CurseforgeSearchPage {
|
|
143
|
+
data: CurseforgeMod[];
|
|
144
|
+
pagination: CurseforgePagination;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface CurseforgeFilesPage {
|
|
148
|
+
data: CurseforgeFile[];
|
|
149
|
+
pagination: CurseforgePagination;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface CurseforgeSearchQuery {
|
|
153
|
+
query?: string;
|
|
154
|
+
classId?: number;
|
|
155
|
+
categoryId?: number;
|
|
156
|
+
gameVersion?: string;
|
|
157
|
+
modLoaderType?: number;
|
|
158
|
+
sort?: CurseforgeSort;
|
|
159
|
+
sortOrder?: CurseforgeSortOrder;
|
|
160
|
+
index?: number;
|
|
161
|
+
pageSize?: number;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface CurseforgeModFilesQuery {
|
|
165
|
+
gameVersion?: string;
|
|
166
|
+
modLoaderType?: number;
|
|
167
|
+
index?: number;
|
|
168
|
+
pageSize?: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface CurseforgeCatalogOptions {
|
|
172
|
+
gameId: number;
|
|
173
|
+
searchCacheSeconds?: number;
|
|
174
|
+
projectCacheSeconds?: number;
|
|
175
|
+
categoryCacheSeconds?: number;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface CurseforgeCatalog {
|
|
179
|
+
ready(): boolean;
|
|
180
|
+
search(query?: CurseforgeSearchQuery): Promise<CurseforgeSearchPage>;
|
|
181
|
+
mod(modId: string | number): Promise<CurseforgeMod>;
|
|
182
|
+
mods(modIds: number[]): Promise<CurseforgeMod[]>;
|
|
183
|
+
modFiles(modId: string | number, query?: CurseforgeModFilesQuery): Promise<CurseforgeFilesPage>;
|
|
184
|
+
file(modId: string | number, fileId: string | number): Promise<CurseforgeFile>;
|
|
185
|
+
files(fileIds: number[]): Promise<CurseforgeFile[]>;
|
|
186
|
+
categories(classId?: number): Promise<CurseforgeCategory[]>;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export const curseforgeSha1 = (file: CurseforgeFile) => {
|
|
190
|
+
return file.hashes.find((hash) => hash.algo === CurseforgeHash.Sha1)?.value.toLowerCase() ?? null;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const pathSegment = (segment: string) => {
|
|
194
|
+
return encodeURIComponent(decodeURIComponent(segment));
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export const curseforgeDownloadUrl = (file: CurseforgeFile): string | null => {
|
|
198
|
+
if (!file.downloadUrl || !URL.canParse(file.downloadUrl)) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const remote = new URL(file.downloadUrl);
|
|
203
|
+
|
|
204
|
+
if (remote.protocol !== "https:" || !remote.hostname.endsWith(CURSEFORGE_CDN_SUFFIX)) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
return `${remote.origin}${remote.pathname.split("/").map(pathSegment).join("/")}`;
|
|
210
|
+
} catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
export const curseforgeFallbackUrl = (fileId: number, fileName: string) => {
|
|
216
|
+
const id = String(fileId);
|
|
217
|
+
const folder =
|
|
218
|
+
id.length >= SPLIT_ID_LENGTH ? id.slice(0, SPLIT_ID_FOLDER) : String(Math.floor(fileId / LEGACY_ID_DIVISOR));
|
|
219
|
+
const remainder = id.length >= SPLIT_ID_LENGTH ? id.slice(SPLIT_ID_FOLDER) : id;
|
|
220
|
+
|
|
221
|
+
return `${CURSEFORGE_DOWNLOAD}/files/${folder}/${remainder}/${encodeURIComponent(fileName)}`;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
export const isCurseforgeServerFile = (file: CurseforgeFile) => {
|
|
225
|
+
let client = false;
|
|
226
|
+
|
|
227
|
+
for (const tag of file.gameVersions) {
|
|
228
|
+
const lowered = tag.toLowerCase();
|
|
229
|
+
|
|
230
|
+
if (lowered === CURSEFORGE_SERVER_TAG) {
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (lowered === CURSEFORGE_CLIENT_TAG) {
|
|
235
|
+
client = true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return !client;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
export interface CurseforgeSelect {
|
|
243
|
+
gameVersion?: string | null;
|
|
244
|
+
stableOnly?: boolean;
|
|
245
|
+
serverOnly?: boolean;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export const selectCurseforgeFile = (files: CurseforgeFile[], select: CurseforgeSelect = {}): CurseforgeFile | null => {
|
|
249
|
+
let usable = files.filter((file) => file.isAvailable);
|
|
250
|
+
|
|
251
|
+
if (select.serverOnly === true) {
|
|
252
|
+
usable = usable.filter(isCurseforgeServerFile);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (select.stableOnly === true) {
|
|
256
|
+
const stable = usable.filter((file) => file.releaseType === CurseforgeReleaseType.Release);
|
|
257
|
+
|
|
258
|
+
usable = stable.length > 0 ? stable : usable;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const newest = [
|
|
262
|
+
...usable,
|
|
263
|
+
].sort((left, right) => right.id - left.id);
|
|
264
|
+
|
|
265
|
+
if (newest.length === 0) {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (!select.gameVersion) {
|
|
270
|
+
return newest.at(0) ?? null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return newest.find((file) => file.gameVersions.includes(select.gameVersion ?? "")) ?? newest.at(0) ?? null;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const appendOptional = (search: URLSearchParams, optional: Record<string, string | number | undefined>) => {
|
|
277
|
+
for (const [key, value] of Object.entries(optional)) {
|
|
278
|
+
if (value !== undefined && String(value).length > 0) {
|
|
279
|
+
search.set(key, String(value));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const ALPHABETICAL_SORTS = [
|
|
285
|
+
CurseforgeSort.Name,
|
|
286
|
+
CurseforgeSort.Author,
|
|
287
|
+
];
|
|
288
|
+
|
|
289
|
+
const defaultSortOrder = (sort: CurseforgeSort) => {
|
|
290
|
+
return ALPHABETICAL_SORTS.includes(sort) ? CurseforgeSortOrder.Asc : CurseforgeSortOrder.Desc;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const searchQuery = (gameId: number, query: CurseforgeSearchQuery) => {
|
|
294
|
+
const search = new URLSearchParams({
|
|
295
|
+
gameId: String(gameId),
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
appendOptional(search, {
|
|
299
|
+
searchFilter: query.query,
|
|
300
|
+
classId: query.classId,
|
|
301
|
+
categoryId: query.categoryId,
|
|
302
|
+
gameVersion: query.gameVersion,
|
|
303
|
+
modLoaderType: query.modLoaderType,
|
|
304
|
+
sortField: query.sort,
|
|
305
|
+
index: query.index,
|
|
306
|
+
pageSize: query.pageSize,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
if (query.sort !== undefined) {
|
|
310
|
+
search.set("sortOrder", query.sortOrder ?? defaultSortOrder(query.sort));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return search.toString();
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const modFilesQuery = (query: CurseforgeModFilesQuery) => {
|
|
317
|
+
const search = new URLSearchParams();
|
|
318
|
+
|
|
319
|
+
appendOptional(search, {
|
|
320
|
+
gameVersion: query.gameVersion,
|
|
321
|
+
modLoaderType: query.modLoaderType,
|
|
322
|
+
index: query.index,
|
|
323
|
+
pageSize: query.pageSize,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
return search.toString();
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
export const createCurseforgeCatalog = (
|
|
330
|
+
context: BridgeContext,
|
|
331
|
+
options: CurseforgeCatalogOptions,
|
|
332
|
+
): CurseforgeCatalog => {
|
|
333
|
+
const projectCacheSeconds = options.projectCacheSeconds ?? PROJECT_CACHE_SECONDS;
|
|
334
|
+
const searchCacheSeconds = options.searchCacheSeconds ?? SEARCH_CACHE_SECONDS;
|
|
335
|
+
const categoryCacheSeconds = options.categoryCacheSeconds ?? CATEGORY_CACHE_SECONDS;
|
|
336
|
+
|
|
337
|
+
const request = async <Result>(url: string, cacheSeconds: number, body?: unknown): Promise<Result> => {
|
|
338
|
+
const key = context.secret(CURSEFORGE_SECRET);
|
|
339
|
+
|
|
340
|
+
if (!key) {
|
|
341
|
+
throw new BridgeSecretError(CURSEFORGE_SECRET, "curseforge needs an api key before it can be searched");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
return await context.net.json<Result>(url, {
|
|
346
|
+
headers: {
|
|
347
|
+
"x-api-key": key,
|
|
348
|
+
},
|
|
349
|
+
cacheSeconds,
|
|
350
|
+
...(body === undefined
|
|
351
|
+
? {}
|
|
352
|
+
: {
|
|
353
|
+
body: JSON.stringify(body),
|
|
354
|
+
httpMethod: BridgeHttpMethod.Post,
|
|
355
|
+
}),
|
|
356
|
+
});
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if (!(error instanceof BridgeNetError) || error.status === null) {
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (REJECTED_STATUSES.includes(error.status)) {
|
|
363
|
+
throw new BridgeSecretError(CURSEFORGE_SECRET, `curseforge rejected the configured key — ${error.message}`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (THROTTLED_STATUSES.includes(error.status)) {
|
|
367
|
+
throw new BridgeFailureError(
|
|
368
|
+
BridgeFailureCode.CatalogRateLimited,
|
|
369
|
+
"curseforge asked us to slow down, wait a moment and try again",
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
throw error;
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const batched = async <Entry>(url: string, field: string, ids: number[]): Promise<Entry[]> => {
|
|
378
|
+
const wanted = [
|
|
379
|
+
...new Set(ids),
|
|
380
|
+
].sort((left, right) => left - right);
|
|
381
|
+
const entries: Entry[] = [];
|
|
382
|
+
|
|
383
|
+
for (let cursor = 0; cursor < wanted.length; cursor += BATCH) {
|
|
384
|
+
const page = await request<{
|
|
385
|
+
data: Entry[];
|
|
386
|
+
}>(url, projectCacheSeconds, {
|
|
387
|
+
[field]: wanted.slice(cursor, cursor + BATCH),
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
entries.push(...page.data);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return entries;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
ready() {
|
|
398
|
+
return context.secret(CURSEFORGE_SECRET) !== null;
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
async search(query = {}) {
|
|
402
|
+
return await request<CurseforgeSearchPage>(
|
|
403
|
+
`${CURSEFORGE_API}/mods/search?${searchQuery(options.gameId, query)}`,
|
|
404
|
+
searchCacheSeconds,
|
|
405
|
+
);
|
|
406
|
+
},
|
|
407
|
+
|
|
408
|
+
async mod(modId) {
|
|
409
|
+
const single = await request<{
|
|
410
|
+
data: CurseforgeMod;
|
|
411
|
+
}>(`${CURSEFORGE_API}/mods/${encodeURIComponent(String(modId))}`, projectCacheSeconds);
|
|
412
|
+
|
|
413
|
+
return single.data;
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
async mods(modIds) {
|
|
417
|
+
return await batched<CurseforgeMod>(`${CURSEFORGE_API}/mods`, "modIds", modIds);
|
|
418
|
+
},
|
|
419
|
+
|
|
420
|
+
async modFiles(modId, query = {}) {
|
|
421
|
+
const search = modFilesQuery(query);
|
|
422
|
+
const suffix = search.length > 0 ? `?${search}` : "";
|
|
423
|
+
|
|
424
|
+
return await request<CurseforgeFilesPage>(
|
|
425
|
+
`${CURSEFORGE_API}/mods/${encodeURIComponent(String(modId))}/files${suffix}`,
|
|
426
|
+
projectCacheSeconds,
|
|
427
|
+
);
|
|
428
|
+
},
|
|
429
|
+
|
|
430
|
+
async file(modId, fileId) {
|
|
431
|
+
const single = await request<{
|
|
432
|
+
data: CurseforgeFile;
|
|
433
|
+
}>(
|
|
434
|
+
`${CURSEFORGE_API}/mods/${encodeURIComponent(String(modId))}/files/${encodeURIComponent(String(fileId))}`,
|
|
435
|
+
projectCacheSeconds,
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
return single.data;
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
async files(fileIds) {
|
|
442
|
+
return await batched<CurseforgeFile>(`${CURSEFORGE_API}/mods/files`, "fileIds", fileIds);
|
|
443
|
+
},
|
|
444
|
+
|
|
445
|
+
async categories(classId) {
|
|
446
|
+
const search = new URLSearchParams({
|
|
447
|
+
gameId: String(options.gameId),
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
if (classId !== undefined) {
|
|
451
|
+
search.set("classId", String(classId));
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const page = await request<{
|
|
455
|
+
data: CurseforgeCategory[];
|
|
456
|
+
}>(`${CURSEFORGE_API}/categories?${search.toString()}`, categoryCacheSeconds);
|
|
457
|
+
|
|
458
|
+
return page.data;
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseThunderstoreManifest, thunderstoreDependency, thunderstoreDownloadUrl } from "./thunderstore";
|
|
3
|
+
|
|
4
|
+
describe("thunderstoreDownloadUrl", () => {
|
|
5
|
+
test("points straight at the cdn so no redirect has to be followed", () => {
|
|
6
|
+
expect(thunderstoreDownloadUrl("denikson-BepInExPack_Valheim-5.4.2202")).toBe(
|
|
7
|
+
"https://gcdn.thunderstore.io/live/repository/packages/denikson-BepInExPack_Valheim-5.4.2202.zip",
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("escapes a package name", () => {
|
|
12
|
+
expect(thunderstoreDownloadUrl("a b-c-1.0.0")).toContain("a%20b-c-1.0.0.zip");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("parseThunderstoreManifest", () => {
|
|
17
|
+
test("reads name, version and dependencies", () => {
|
|
18
|
+
expect(
|
|
19
|
+
parseThunderstoreManifest(
|
|
20
|
+
JSON.stringify({
|
|
21
|
+
name: "MyMod",
|
|
22
|
+
version_number: "1.2.3",
|
|
23
|
+
description: "does things",
|
|
24
|
+
dependencies: [
|
|
25
|
+
"denikson-BepInExPack_Valheim-5.4.2202",
|
|
26
|
+
],
|
|
27
|
+
}),
|
|
28
|
+
),
|
|
29
|
+
).toEqual({
|
|
30
|
+
name: "MyMod",
|
|
31
|
+
version_number: "1.2.3",
|
|
32
|
+
description: "does things",
|
|
33
|
+
dependencies: [
|
|
34
|
+
"denikson-BepInExPack_Valheim-5.4.2202",
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("defaults missing dependencies to an empty list", () => {
|
|
40
|
+
expect(
|
|
41
|
+
parseThunderstoreManifest(
|
|
42
|
+
JSON.stringify({
|
|
43
|
+
name: "MyMod",
|
|
44
|
+
version_number: "1.0.0",
|
|
45
|
+
}),
|
|
46
|
+
)?.dependencies,
|
|
47
|
+
).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("drops entries that are not strings", () => {
|
|
51
|
+
expect(
|
|
52
|
+
parseThunderstoreManifest(
|
|
53
|
+
JSON.stringify({
|
|
54
|
+
name: "MyMod",
|
|
55
|
+
version_number: "1.0.0",
|
|
56
|
+
dependencies: [
|
|
57
|
+
"a-b-1.0.0",
|
|
58
|
+
7,
|
|
59
|
+
],
|
|
60
|
+
}),
|
|
61
|
+
)?.dependencies,
|
|
62
|
+
).toEqual([
|
|
63
|
+
"a-b-1.0.0",
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("returns null for anything that is not a manifest", () => {
|
|
68
|
+
expect(parseThunderstoreManifest("")).toBeNull();
|
|
69
|
+
expect(parseThunderstoreManifest("[]")).toBeNull();
|
|
70
|
+
expect(
|
|
71
|
+
parseThunderstoreManifest(
|
|
72
|
+
JSON.stringify({
|
|
73
|
+
name: "MyMod",
|
|
74
|
+
}),
|
|
75
|
+
),
|
|
76
|
+
).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("thunderstoreDependency", () => {
|
|
81
|
+
test("splits a dependency reference into its three parts", () => {
|
|
82
|
+
expect(thunderstoreDependency("denikson-BepInExPack_Valheim-5.4.2202")).toEqual({
|
|
83
|
+
namespace: "denikson",
|
|
84
|
+
name: "BepInExPack_Valheim",
|
|
85
|
+
version: "5.4.2202",
|
|
86
|
+
fullName: "denikson-BepInExPack_Valheim-5.4.2202",
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("returns null for a reference it cannot split", () => {
|
|
91
|
+
expect(thunderstoreDependency("denikson-BepInExPack")).toBeNull();
|
|
92
|
+
expect(thunderstoreDependency("a-b-c-d")).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
});
|