@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
@@ -16,8 +16,10 @@ import {
16
16
  zHostPort,
17
17
  zImageReference,
18
18
  zPort,
19
+ zPortKey,
19
20
  zSlug,
20
21
  } from "./primitives";
22
+ import { ManifestPlatform, manifestRuntimeSchema, WINE_MIN_SHM_MB } from "./runtime";
21
23
 
22
24
  export const MANIFEST_SCHEMA_VERSION = "serverk.gg/v1";
23
25
 
@@ -50,12 +52,19 @@ const manifestPortActivationSchema = z
50
52
 
51
53
  export type ManifestPortActivation = z.infer<typeof manifestPortActivationSchema>;
52
54
 
55
+ export const PORT_FOLLOW_MIN_OFFSET = 1;
56
+
57
+ export const PORT_FOLLOW_MAX_OFFSET = 16;
58
+
59
+ const manifestPortFollowSchema = z.object({
60
+ key: zPortKey,
61
+ offset: z.int().min(PORT_FOLLOW_MIN_OFFSET).max(PORT_FOLLOW_MAX_OFFSET),
62
+ });
63
+
64
+ export type ManifestPortFollow = z.infer<typeof manifestPortFollowSchema>;
65
+
53
66
  const manifestPortSchema = z.object({
54
- key: z
55
- .string()
56
- .min(1)
57
- .max(32)
58
- .regex(/^[a-z][a-z0-9-]*$/, "must be lowercase letters, digits and dashes, starting with a letter"),
67
+ key: zPortKey,
59
68
  containerPort: zPort,
60
69
  protocol: z.enum(PortProtocol).default(PortProtocol.Tcp),
61
70
  hostRange: manifestHostRangeSchema,
@@ -83,16 +92,47 @@ const manifestPortSchema = z.object({
83
92
  description:
84
93
  "Conditions on the server's variables that must all hold for the game to listen on this port. The port is still allocated; the panel hides the address otherwise.",
85
94
  }),
95
+ mirror: z.boolean().default(false).meta({
96
+ description:
97
+ "Binds the allocated host port as the container port too, so the game advertises the number players dial. Declare it when the game reports its own port to a master server or an A2S client.",
98
+ }),
99
+ follows: manifestPortFollowSchema.optional().meta({
100
+ description:
101
+ "Allocates this port at the named port's host port plus the offset, for games that derive one port from another. The named port must use the same protocol.",
102
+ }),
86
103
  });
87
104
 
88
105
  export type ManifestPort = z.infer<typeof manifestPortSchema>;
89
106
 
90
- const manifestContainerSchema = z.object({
91
- image: zImageReference.meta({
92
- description: `Image repository, always "${IMAGE_NAMESPACE}/" followed by metadata.id.`,
93
- }),
94
- ports: z.array(manifestPortSchema).min(1).max(8),
95
- });
107
+ const manifestContainerSchema = z
108
+ .object({
109
+ image: zImageReference.meta({
110
+ description: `Image repository, always "${IMAGE_NAMESPACE}/" followed by metadata.id.`,
111
+ }),
112
+ ports: z.array(manifestPortSchema).min(1).max(8),
113
+ runtime: manifestRuntimeSchema.prefault({}).meta({
114
+ description: "How the container is built and how long the platform waits for it.",
115
+ }),
116
+ })
117
+ .check((ctx) => {
118
+ if (ctx.issues.length > 0) {
119
+ return;
120
+ }
121
+
122
+ const { runtime } = ctx.value;
123
+
124
+ if (runtime.platform === ManifestPlatform.Wine && runtime.shmMb < WINE_MIN_SHM_MB) {
125
+ ctx.issues.push({
126
+ code: "custom",
127
+ message: `must be at least ${WINE_MIN_SHM_MB} when platform is "${ManifestPlatform.Wine}"`,
128
+ input: runtime.shmMb,
129
+ path: [
130
+ "runtime",
131
+ "shmMb",
132
+ ],
133
+ });
134
+ }
135
+ });
96
136
 
97
137
  const manifestMetadataSchema = z.object({
98
138
  id: zSlug.meta({
@@ -228,6 +268,91 @@ const hostRangesOverlap = (a: ManifestPort, b: ManifestPort) => {
228
268
  return a.hostRange.min <= b.hostRange.max && b.hostRange.min <= a.hostRange.max;
229
269
  };
230
270
 
271
+ const followLeaderKey = (port: ManifestPort) => {
272
+ return port.follows?.key ?? port.key;
273
+ };
274
+
275
+ const sameFollowGroup = (a: ManifestPort, b: ManifestPort) => {
276
+ if (!a.follows && !b.follows) {
277
+ return false;
278
+ }
279
+
280
+ return followLeaderKey(a) === followLeaderKey(b);
281
+ };
282
+
283
+ const followIssues = (ports: ManifestPort[]): CheckIssue[] => {
284
+ const issues: CheckIssue[] = [];
285
+ const byKey = new Map(
286
+ ports.map((port) => [
287
+ port.key,
288
+ port,
289
+ ]),
290
+ );
291
+ const followed = new Set(ports.map((port) => port.follows?.key).filter((key): key is string => key !== undefined));
292
+
293
+ ports.forEach((port, index) => {
294
+ const follows = port.follows;
295
+
296
+ if (!follows) {
297
+ return;
298
+ }
299
+
300
+ const path = [
301
+ "container",
302
+ "ports",
303
+ index,
304
+ "follows",
305
+ "key",
306
+ ];
307
+
308
+ if (follows.key === port.key) {
309
+ issues.push({
310
+ code: "custom",
311
+ message: `port "${port.key}" cannot follow itself`,
312
+ input: follows.key,
313
+ path,
314
+ });
315
+
316
+ return;
317
+ }
318
+
319
+ const leader = byKey.get(follows.key);
320
+
321
+ if (!leader) {
322
+ issues.push({
323
+ code: "custom",
324
+ message: `"${follows.key}" is not a declared port`,
325
+ input: follows.key,
326
+ path,
327
+ });
328
+
329
+ return;
330
+ }
331
+
332
+ if (leader.protocol !== port.protocol) {
333
+ issues.push({
334
+ code: "custom",
335
+ message: `"${follows.key}" is a ${leader.protocol} port — a port only follows one of its own protocol`,
336
+ input: follows.key,
337
+ path,
338
+ });
339
+
340
+ return;
341
+ }
342
+
343
+ if (followed.has(port.key)) {
344
+ issues.push({
345
+ code: "custom",
346
+ message: `port "${port.key}" is followed by another port and cannot follow one itself`,
347
+ input: follows.key,
348
+ path,
349
+ });
350
+ }
351
+ });
352
+
353
+ return issues;
354
+ };
355
+
231
356
  const portIssues = (ports: ManifestPort[]): CheckIssue[] => {
232
357
  const issues = duplicateIssues(
233
358
  ports,
@@ -246,7 +371,7 @@ const portIssues = (ports: ManifestPort[]): CheckIssue[] => {
246
371
  const a = ports[index];
247
372
  const b = ports[other];
248
373
 
249
- if (!a || !b || a.protocol !== b.protocol || !hostRangesOverlap(a, b)) {
374
+ if (!a || !b || a.protocol !== b.protocol || !hostRangesOverlap(a, b) || sameFollowGroup(a, b)) {
250
375
  continue;
251
376
  }
252
377
 
@@ -264,7 +389,10 @@ const portIssues = (ports: ManifestPort[]): CheckIssue[] => {
264
389
  }
265
390
  }
266
391
 
267
- return issues;
392
+ return [
393
+ ...issues,
394
+ ...followIssues(ports),
395
+ ];
268
396
  };
269
397
 
270
398
  const guideIssues = (guideTags: GuideTag[], guides: GuideEntry[]): CheckIssue[] => {
@@ -20,6 +20,12 @@ export const zHostPort = z
20
20
 
21
21
  export const zHexColor = z.stringFormat("hexColor", /^#[0-9a-fA-F]{6}$/, "must be a six-digit hex colour like #4a8fd4");
22
22
 
23
+ export const zPortKey = z
24
+ .string()
25
+ .min(1)
26
+ .max(32)
27
+ .regex(/^[a-z][a-z0-9-]*$/, "must be lowercase letters, digits and dashes, starting with a letter");
28
+
23
29
  export const zEnvKey = z
24
30
  .string()
25
31
  .min(2)
@@ -0,0 +1,89 @@
1
+ import { z } from "zod";
2
+ import type { Manifest } from "./manifest";
3
+
4
+ export enum ManifestPlatform {
5
+ Linux = "linux",
6
+ Wine = "wine",
7
+ }
8
+
9
+ export enum ManifestCapability {
10
+ SysNice = "SYS_NICE",
11
+ IpcLock = "IPC_LOCK",
12
+ SysPtrace = "SYS_PTRACE",
13
+ }
14
+
15
+ export const INSTALL_BUDGET_MIN_MINUTES = 5;
16
+
17
+ export const INSTALL_BUDGET_MAX_MINUTES = 180;
18
+
19
+ export const INSTALL_BUDGET_DEFAULT_MINUTES = 15;
20
+
21
+ export const BOOT_BUDGET_MIN_MINUTES = 2;
22
+
23
+ export const BOOT_BUDGET_MAX_MINUTES = 60;
24
+
25
+ export const BOOT_BUDGET_DEFAULT_MINUTES = 12;
26
+
27
+ export const SHM_MIN_MB = 64;
28
+
29
+ export const SHM_MAX_MB = 4096;
30
+
31
+ export const SHM_DEFAULT_MB = 64;
32
+
33
+ export const WINE_MIN_SHM_MB = 256;
34
+
35
+ export const PIDS_LIMIT_MIN = 128;
36
+
37
+ export const PIDS_LIMIT_MAX = 4096;
38
+
39
+ export const PIDS_LIMIT_DEFAULT = 512;
40
+
41
+ export const MANIFEST_CAPABILITIES_LIMIT = 3;
42
+
43
+ export const manifestRuntimeSchema = z.object({
44
+ platform: z.enum(ManifestPlatform).default(ManifestPlatform.Linux).meta({
45
+ description:
46
+ "Whether the game binary is a Linux executable or a Windows one the image runs through Wine or Proton.",
47
+ }),
48
+ installBudgetMinutes: z
49
+ .int()
50
+ .min(INSTALL_BUDGET_MIN_MINUTES)
51
+ .max(INSTALL_BUDGET_MAX_MINUTES)
52
+ .default(INSTALL_BUDGET_DEFAULT_MINUTES)
53
+ .meta({
54
+ description: "How long the first install may take before the platform gives up on it, in minutes.",
55
+ }),
56
+ bootBudgetMinutes: z
57
+ .int()
58
+ .min(BOOT_BUDGET_MIN_MINUTES)
59
+ .max(BOOT_BUDGET_MAX_MINUTES)
60
+ .default(BOOT_BUDGET_DEFAULT_MINUTES)
61
+ .meta({
62
+ description: "How long the game may take to report itself running after the install, in minutes.",
63
+ }),
64
+ shmMb: z
65
+ .int()
66
+ .min(SHM_MIN_MB)
67
+ .max(SHM_MAX_MB)
68
+ .default(SHM_DEFAULT_MB)
69
+ .meta({
70
+ description: `Size of /dev/shm inside the container, in MB. Wine and Proton games need at least ${WINE_MIN_SHM_MB}.`,
71
+ }),
72
+ pidsLimit: z.int().min(PIDS_LIMIT_MIN).max(PIDS_LIMIT_MAX).default(PIDS_LIMIT_DEFAULT).meta({
73
+ description: "How many processes and threads the container may hold at once.",
74
+ }),
75
+ capabilities: z.array(z.enum(ManifestCapability)).max(MANIFEST_CAPABILITIES_LIMIT).default([]).meta({
76
+ description: "Linux capabilities added back on top of the dropped set, from the allow-list this schema declares.",
77
+ }),
78
+ });
79
+
80
+ export type ManifestRuntime = z.infer<typeof manifestRuntimeSchema>;
81
+
82
+ export const MANIFEST_RUNTIME_DEFAULTS: ManifestRuntime = manifestRuntimeSchema.parse({});
83
+
84
+ export const manifestRuntimeOf = (manifest: Manifest): ManifestRuntime => {
85
+ return {
86
+ ...MANIFEST_RUNTIME_DEFAULTS,
87
+ ...manifest.container.runtime,
88
+ };
89
+ };
@@ -0,0 +1,2 @@
1
+ export * from "./loopback";
2
+ export * from "./loopbackMethod";
@@ -0,0 +1,152 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { BridgeNetError } from "../core";
3
+ import { loopbackJson, loopbackText } from "./loopback";
4
+ import { LoopbackMethod } from "./loopbackMethod";
5
+
6
+ const serve = (handler: (request: Request) => Response | Promise<Response>) => {
7
+ return Bun.serve({
8
+ hostname: "127.0.0.1",
9
+ port: 0,
10
+ fetch: handler,
11
+ });
12
+ };
13
+
14
+ describe("loopbackJson", () => {
15
+ test("reads a json body from a loopback server", async () => {
16
+ const server = serve(() =>
17
+ Response.json({
18
+ online: 3,
19
+ }),
20
+ );
21
+
22
+ try {
23
+ expect(
24
+ await loopbackJson<{
25
+ online: number;
26
+ }>(`http://127.0.0.1:${server.port}/state`),
27
+ ).toEqual({
28
+ online: 3,
29
+ });
30
+ } finally {
31
+ await server.stop(true);
32
+ }
33
+ });
34
+
35
+ test("sends the method, headers and body it is given", async () => {
36
+ let seen = "";
37
+
38
+ const server = serve(async (request) => {
39
+ seen = `${request.method} ${request.headers.get("x-token") ?? ""} ${await request.text()}`;
40
+
41
+ return Response.json({
42
+ ok: true,
43
+ });
44
+ });
45
+
46
+ try {
47
+ await loopbackJson(`http://127.0.0.1:${server.port}/run`, {
48
+ method: LoopbackMethod.Post,
49
+ headers: {
50
+ "x-token": "abc",
51
+ },
52
+ body: '{"command":"save"}',
53
+ });
54
+
55
+ expect(seen).toBe('POST abc {"command":"save"}');
56
+ } finally {
57
+ await server.stop(true);
58
+ }
59
+ });
60
+
61
+ test("raises a net error carrying the status when the server refuses", async () => {
62
+ const server = serve(
63
+ () =>
64
+ new Response("nope", {
65
+ status: 401,
66
+ }),
67
+ );
68
+
69
+ try {
70
+ const error = await loopbackJson(`http://127.0.0.1:${server.port}/`).catch((reason: unknown) => reason);
71
+
72
+ expect(error).toBeInstanceOf(BridgeNetError);
73
+ expect((error as BridgeNetError).status).toBe(401);
74
+ } finally {
75
+ await server.stop(true);
76
+ }
77
+ });
78
+
79
+ test("raises a net error when the body is not json", async () => {
80
+ const server = serve(() => new Response("not json"));
81
+
82
+ try {
83
+ const error = await loopbackJson(`http://127.0.0.1:${server.port}/`).catch((reason: unknown) => reason);
84
+
85
+ expect(error).toBeInstanceOf(BridgeNetError);
86
+ expect((error as BridgeNetError).status).toBeNull();
87
+ } finally {
88
+ await server.stop(true);
89
+ }
90
+ });
91
+
92
+ test("times out against a server that never answers", async () => {
93
+ const server = serve(async () => {
94
+ await Bun.sleep(600);
95
+
96
+ return new Response("late");
97
+ });
98
+
99
+ try {
100
+ const error = await loopbackText(`http://127.0.0.1:${server.port}/`, {
101
+ timeoutMs: 150,
102
+ }).catch((reason: unknown) => reason);
103
+
104
+ expect(error).toBeInstanceOf(BridgeNetError);
105
+ } finally {
106
+ await server.stop(true);
107
+ }
108
+ });
109
+ });
110
+
111
+ describe("loopback guard", () => {
112
+ test("refuses a host that is not loopback", async () => {
113
+ for (const url of [
114
+ "https://api.curseforge.com/v1",
115
+ "http://10.0.0.5:8080/",
116
+ "http://127.0.0.1.evil.test/",
117
+ ]) {
118
+ const error = await loopbackText(url).catch((reason: unknown) => reason);
119
+
120
+ expect(error).toBeInstanceOf(BridgeNetError);
121
+ expect((error as BridgeNetError).message).toContain("is not a loopback host");
122
+ }
123
+ });
124
+
125
+ test("refuses a scheme that is not http", async () => {
126
+ const error = await loopbackText("file:///etc/passwd").catch((reason: unknown) => reason);
127
+
128
+ expect(error).toBeInstanceOf(BridgeNetError);
129
+ expect((error as BridgeNetError).message).toContain("is not an http url");
130
+ });
131
+
132
+ test("refuses a header carrying a line break", async () => {
133
+ const error = await loopbackText("http://127.0.0.1:1/", {
134
+ headers: {
135
+ "x-token": "a\r\nx-admin: yes",
136
+ },
137
+ }).catch((reason: unknown) => reason);
138
+
139
+ expect(error).toBeInstanceOf(BridgeNetError);
140
+ expect((error as BridgeNetError).message).toContain("line break");
141
+ });
142
+
143
+ test("accepts localhost", async () => {
144
+ const server = serve(() => new Response("pong"));
145
+
146
+ try {
147
+ expect(await loopbackText(`http://localhost:${server.port}/`)).toBe("pong");
148
+ } finally {
149
+ await server.stop(true);
150
+ }
151
+ });
152
+ });
@@ -0,0 +1,109 @@
1
+ import { BridgeNetError } from "../core";
2
+ import { LoopbackMethod } from "./loopbackMethod";
3
+
4
+ const LOOPBACK_TIMEOUT_MS = 10_000;
5
+
6
+ const LOOPBACK_HOSTS = [
7
+ "127.0.0.1",
8
+ "localhost",
9
+ "::1",
10
+ "[::1]",
11
+ ];
12
+
13
+ const LINE_BREAK = /[\r\n]/;
14
+
15
+ export interface LoopbackOptions {
16
+ method?: LoopbackMethod;
17
+ headers?: Record<string, string>;
18
+ body?: string;
19
+ timeoutMs?: number;
20
+ insecureTls?: boolean;
21
+ }
22
+
23
+ const loopbackUrl = (url: string) => {
24
+ let remote: URL;
25
+
26
+ try {
27
+ remote = new URL(url);
28
+ } catch {
29
+ throw new BridgeNetError(null, `"${url}" is not a url`);
30
+ }
31
+
32
+ if (remote.protocol !== "http:" && remote.protocol !== "https:") {
33
+ throw new BridgeNetError(null, `${remote.protocol} is not an http url`);
34
+ }
35
+
36
+ if (!LOOPBACK_HOSTS.includes(remote.hostname.toLowerCase())) {
37
+ throw new BridgeNetError(null, `${remote.hostname} is not a loopback host`);
38
+ }
39
+
40
+ return remote;
41
+ };
42
+
43
+ const loopbackHeaders = (headers: Record<string, string> = {}) => {
44
+ const checked: Record<string, string> = {};
45
+
46
+ for (const [key, value] of Object.entries(headers)) {
47
+ if (LINE_BREAK.test(key) || LINE_BREAK.test(value)) {
48
+ throw new BridgeNetError(null, "a header contains a line break");
49
+ }
50
+
51
+ checked[key] = value;
52
+ }
53
+
54
+ return checked;
55
+ };
56
+
57
+ const loopbackFetch = async (url: string, options: LoopbackOptions) => {
58
+ const remote = loopbackUrl(url);
59
+ const method = options.method ?? LoopbackMethod.Get;
60
+ const body = method === LoopbackMethod.Get || method === LoopbackMethod.Delete ? null : (options.body ?? null);
61
+
62
+ let response: Response;
63
+
64
+ try {
65
+ response = await fetch(remote, {
66
+ method,
67
+ headers: loopbackHeaders(options.headers),
68
+ ...(body === null
69
+ ? {}
70
+ : {
71
+ body,
72
+ }),
73
+ signal: AbortSignal.timeout(options.timeoutMs ?? LOOPBACK_TIMEOUT_MS),
74
+ ...(options.insecureTls === true
75
+ ? {
76
+ tls: {
77
+ rejectUnauthorized: false,
78
+ },
79
+ }
80
+ : {}),
81
+ });
82
+ } catch (error) {
83
+ const detail = error instanceof Error && error.message.length > 0 ? ` — ${error.message}` : "";
84
+
85
+ throw new BridgeNetError(null, `${remote.host} could not be reached${detail}`);
86
+ }
87
+
88
+ if (!response.ok) {
89
+ await response.text();
90
+
91
+ throw new BridgeNetError(response.status, `${remote.host} answered ${response.status}`);
92
+ }
93
+
94
+ return response;
95
+ };
96
+
97
+ export const loopbackText = async (url: string, options: LoopbackOptions = {}): Promise<string> => {
98
+ return await (await loopbackFetch(url, options)).text();
99
+ };
100
+
101
+ export const loopbackJson = async <Result>(url: string, options: LoopbackOptions = {}): Promise<Result> => {
102
+ const text = await loopbackText(url, options);
103
+
104
+ try {
105
+ return JSON.parse(text) as Result;
106
+ } catch {
107
+ throw new BridgeNetError(null, `${new URL(url).host} did not answer with json`);
108
+ }
109
+ };
@@ -0,0 +1,7 @@
1
+ export enum LoopbackMethod {
2
+ Delete = "DELETE",
3
+ Get = "GET",
4
+ Patch = "PATCH",
5
+ Post = "POST",
6
+ Put = "PUT",
7
+ }
@@ -0,0 +1 @@
1
+ export * from "./rosterSync";