@serverkgg/bridge 0.0.8 → 0.0.9
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 +3 -3
- package/src/cli/validate.ts +1 -0
- package/src/context/bridgeContext.ts +10 -0
- package/src/context/index.ts +1 -0
- package/src/core/bridgeDownloadError.ts +10 -0
- package/src/core/bridgeHold.ts +12 -0
- package/src/core/bridgeKind.ts +2 -0
- package/src/core/bridgeUserError.test.ts +23 -0
- package/src/core/bridgeUserError.ts +12 -0
- package/src/core/index.ts +3 -0
- package/src/manifest/index.ts +1 -0
- package/src/manifest/manifest.test.ts +85 -0
- package/src/manifest/manifest.ts +21 -0
- package/src/manifest/presence.ts +48 -0
- package/src/manifest/primitives.ts +6 -0
- package/src/modules/bridge.ts +45 -2
- package/src/modules/bridgeDriver.ts +2 -0
- package/src/modules/bridgeModule.ts +25 -3
- package/src/panel/bridgeDetail.ts +49 -0
- package/src/panel/bridgeLayout.ts +1 -0
- package/src/panel/bridgePanel.ts +30 -0
- package/src/panel/bridgeUploadMode.ts +4 -0
- package/src/panel/index.ts +2 -0
- package/src/protocol/bridgeEgress.ts +8 -1
- package/src/protocol/bridgeEvents.ts +4 -0
- package/src/protocol/bridgeFrame.ts +28 -1
- package/src/protocol/bridgeLimits.ts +10 -0
- package/src/runtime/bridgeActions.ts +7 -0
- package/src/runtime/bridgeDriver.test.ts +477 -2
- package/src/runtime/bridgeDriver.ts +116 -3
- package/src/runtime/bridgeEgress.ts +3 -0
- package/src/runtime/bridgeEvents.ts +7 -5
- package/src/runtime/bridgeFiles.test.ts +625 -15
- package/src/runtime/bridgeFiles.ts +152 -45
- package/src/runtime/bridgeInstall.test.ts +118 -0
- package/src/runtime/bridgeInstall.ts +55 -0
- package/src/runtime/bridgeNet.ts +32 -3
- package/src/runtime/bridgePending.test.ts +148 -0
- package/src/runtime/bridgePending.ts +43 -0
- package/src/runtime/bridgeRegistry.test.ts +134 -0
- package/src/runtime/bridgeRegistry.ts +26 -4
- package/src/runtime/bridgeServer.test.ts +338 -0
- package/src/runtime/bridgeServer.ts +25 -3
- package/src/runtime/bridgeSupervisor.ts +18 -0
- package/src/runtime/bridgeZip.ts +179 -58
- package/src/runtime/serve.ts +64 -4
- package/src/wire/panel.test.ts +166 -0
- package/src/wire/panel.ts +32 -0
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
import type { Dirent } from "node:fs";
|
|
2
2
|
import { chown, mkdir, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
-
import type {
|
|
4
|
-
|
|
3
|
+
import type {
|
|
4
|
+
BridgeDigestAlgorithm,
|
|
5
|
+
BridgeDownloadOptions,
|
|
6
|
+
BridgeExtractOptions,
|
|
7
|
+
BridgeFileEntry,
|
|
8
|
+
BridgeFiles,
|
|
9
|
+
} from "../context";
|
|
10
|
+
import { BridgeDownloadError } from "../core";
|
|
11
|
+
import { compileGlob, compileGlobs, matchesAny } from "../utils";
|
|
5
12
|
import { type BridgeEgress, BridgeEgressCallError, BridgeEgressUnavailableError } from "./bridgeEgress";
|
|
6
|
-
import type {
|
|
13
|
+
import type { BridgeLeveledLogger } from "./bridgeLog";
|
|
7
14
|
import { USER_AGENT } from "./bridgeNet";
|
|
8
15
|
import { BridgePathError, resolveWithin, toSegments } from "./bridgePath";
|
|
9
16
|
import { isPrivileged } from "./bridgePrivilege";
|
|
10
|
-
import {
|
|
17
|
+
import { openZip, ZIP_ENTRY_LIMIT, ZIP_TREE_ENTRY_LIMIT } from "./bridgeZip";
|
|
11
18
|
|
|
12
19
|
const READ_LIMIT = 4 * 1024 * 1024;
|
|
13
20
|
|
|
14
|
-
const DOWNLOAD_LIMIT =
|
|
21
|
+
const DOWNLOAD_LIMIT = 2 * 1024 * 1024 * 1024;
|
|
15
22
|
|
|
16
23
|
const DOWNLOAD_TIMEOUT_MS = 300_000;
|
|
17
24
|
|
|
18
|
-
const ARCHIVE_LIMIT =
|
|
25
|
+
const ARCHIVE_LIMIT = 2 * 1024 * 1024 * 1024;
|
|
19
26
|
|
|
20
27
|
const WALK_DEPTH = 12;
|
|
21
28
|
|
|
@@ -23,18 +30,22 @@ const WALK_LIMIT = 4096;
|
|
|
23
30
|
|
|
24
31
|
const MILESTONE_STEP = 25;
|
|
25
32
|
|
|
26
|
-
const DIGEST_LENGTHS: Record<
|
|
33
|
+
const DIGEST_LENGTHS: Record<BridgeDigestAlgorithm, number> = {
|
|
27
34
|
sha1: 40,
|
|
28
35
|
sha256: 64,
|
|
29
36
|
sha512: 128,
|
|
30
37
|
};
|
|
31
38
|
|
|
39
|
+
const isDigestAlgorithm = (value: string): value is BridgeDigestAlgorithm => {
|
|
40
|
+
return Object.hasOwn(DIGEST_LENGTHS, value);
|
|
41
|
+
};
|
|
42
|
+
|
|
32
43
|
export interface BridgeFilesOptions {
|
|
33
44
|
root: string;
|
|
34
45
|
uid: number;
|
|
35
46
|
gid: number;
|
|
36
47
|
egress: BridgeEgress;
|
|
37
|
-
onLog:
|
|
48
|
+
onLog: BridgeLeveledLogger;
|
|
38
49
|
}
|
|
39
50
|
|
|
40
51
|
interface DownloadSource {
|
|
@@ -45,12 +56,12 @@ interface DownloadSource {
|
|
|
45
56
|
const parseDigest = (digest: string) => {
|
|
46
57
|
const [algo, expected] = digest.split(":");
|
|
47
58
|
|
|
48
|
-
if (!algo || !expected || DIGEST_LENGTHS[algo] !== expected.length) {
|
|
59
|
+
if (!algo || !expected || !isDigestAlgorithm(algo) || DIGEST_LENGTHS[algo] !== expected.length) {
|
|
49
60
|
throw new BridgePathError("invalid", `digest "${digest}" is not supported`);
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
return {
|
|
53
|
-
algo
|
|
64
|
+
algo,
|
|
54
65
|
expected,
|
|
55
66
|
};
|
|
56
67
|
};
|
|
@@ -93,22 +104,87 @@ const directorySize = async (path: string, depth: number): Promise<number> => {
|
|
|
93
104
|
return total;
|
|
94
105
|
};
|
|
95
106
|
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
107
|
+
const SUFFIX_PREFIX = "**/";
|
|
108
|
+
|
|
109
|
+
type ArchiveSelect =
|
|
110
|
+
| {
|
|
111
|
+
kind: "entry";
|
|
112
|
+
value: string;
|
|
113
|
+
}
|
|
114
|
+
| {
|
|
115
|
+
kind: "prefix";
|
|
116
|
+
value: string;
|
|
117
|
+
}
|
|
118
|
+
| {
|
|
119
|
+
kind: "suffix";
|
|
120
|
+
value: string;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const prefixSelect = (select: string): ArchiveSelect => {
|
|
124
|
+
const segments = toSegments(select);
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
kind: "prefix",
|
|
128
|
+
value: segments.length === 0 ? "" : `${segments.join("/")}/`,
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const entrySelect = (select: string): ArchiveSelect => {
|
|
133
|
+
return {
|
|
134
|
+
kind: "entry",
|
|
135
|
+
value: toSegments(select).join("/"),
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const suffixSelect = (select: string): ArchiveSelect => {
|
|
140
|
+
return {
|
|
141
|
+
kind: "suffix",
|
|
142
|
+
value: toSegments(select.slice(SUFFIX_PREFIX.length)).join("/"),
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const compileSelect = (select: string): ArchiveSelect => {
|
|
147
|
+
if (select.startsWith(SUFFIX_PREFIX)) {
|
|
148
|
+
return suffixSelect(select);
|
|
99
149
|
}
|
|
100
150
|
|
|
101
|
-
|
|
151
|
+
return select.endsWith("/") ? prefixSelect(select) : entrySelect(select);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const compileSelects = (select: string | undefined, selects: string[] | undefined) => {
|
|
155
|
+
const compiled: ArchiveSelect[] = [];
|
|
102
156
|
|
|
103
|
-
|
|
157
|
+
if (select !== undefined) {
|
|
158
|
+
compiled.push(prefixSelect(select));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (const entry of selects ?? []) {
|
|
162
|
+
compiled.push(compileSelect(entry));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return compiled;
|
|
104
166
|
};
|
|
105
167
|
|
|
106
|
-
const selectedName = (name: string,
|
|
107
|
-
if (
|
|
168
|
+
const selectedName = (name: string, selects: ArchiveSelect[]) => {
|
|
169
|
+
if (selects.length === 0) {
|
|
108
170
|
return name;
|
|
109
171
|
}
|
|
110
172
|
|
|
111
|
-
|
|
173
|
+
for (const select of selects) {
|
|
174
|
+
if (select.kind === "prefix" && name.startsWith(select.value)) {
|
|
175
|
+
return name.slice(select.value.length);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (select.kind === "entry" && name === select.value) {
|
|
179
|
+
return name;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (select.kind === "suffix" && (name === select.value || name.endsWith(`/${select.value}`))) {
|
|
183
|
+
return select.value;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return null;
|
|
112
188
|
};
|
|
113
189
|
|
|
114
190
|
const treeSegments = (name: string) => {
|
|
@@ -207,6 +283,7 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
|
|
|
207
283
|
const shared = await options.egress.download(remote.href, {
|
|
208
284
|
digest: downloadOptions.digest,
|
|
209
285
|
sizeBytes: downloadOptions.sizeBytes,
|
|
286
|
+
timeoutMs: downloadOptions.timeoutMs,
|
|
210
287
|
});
|
|
211
288
|
|
|
212
289
|
return {
|
|
@@ -215,7 +292,7 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
|
|
|
215
292
|
};
|
|
216
293
|
} catch (error) {
|
|
217
294
|
if (error instanceof BridgeEgressCallError) {
|
|
218
|
-
throw new
|
|
295
|
+
throw new BridgeDownloadError(error.status, `${remote.host} answered ${error.status ?? error.message}`);
|
|
219
296
|
}
|
|
220
297
|
|
|
221
298
|
if (!(error instanceof BridgeEgressUnavailableError)) {
|
|
@@ -229,11 +306,11 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
|
|
|
229
306
|
"user-agent": USER_AGENT,
|
|
230
307
|
},
|
|
231
308
|
redirect: "error",
|
|
232
|
-
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
309
|
+
signal: AbortSignal.timeout(downloadOptions.timeoutMs ?? DOWNLOAD_TIMEOUT_MS),
|
|
233
310
|
});
|
|
234
311
|
|
|
235
312
|
if (!response.ok || !response.body) {
|
|
236
|
-
throw new
|
|
313
|
+
throw new BridgeDownloadError(response.status, `${remote.host} answered ${response.status}`);
|
|
237
314
|
}
|
|
238
315
|
|
|
239
316
|
const declared = Number(response.headers.get("content-length"));
|
|
@@ -333,6 +410,22 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
|
|
|
333
410
|
return resolved.isDirectory ? await directorySize(resolved.target, 0) : resolved.sizeBytes;
|
|
334
411
|
},
|
|
335
412
|
|
|
413
|
+
async digest(path, algorithm) {
|
|
414
|
+
const resolved = await resolveWithin(options.root, path);
|
|
415
|
+
|
|
416
|
+
if (!resolved.exists || resolved.isDirectory) {
|
|
417
|
+
throw new BridgePathError("missing", `path "${path}" is not a readable file`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const hasher = new Bun.CryptoHasher(algorithm);
|
|
421
|
+
|
|
422
|
+
for await (const chunk of Bun.file(resolved.target).stream()) {
|
|
423
|
+
hasher.update(chunk);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return hasher.digest("hex");
|
|
427
|
+
},
|
|
428
|
+
|
|
336
429
|
async download(path, url, downloadOptions: BridgeDownloadOptions = {}) {
|
|
337
430
|
const remote = new URL(url);
|
|
338
431
|
|
|
@@ -340,12 +433,15 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
|
|
|
340
433
|
throw new BridgePathError("invalid", `${remote.host} is not https`);
|
|
341
434
|
}
|
|
342
435
|
|
|
343
|
-
const
|
|
436
|
+
const declared = downloadOptions.sizeBytes;
|
|
344
437
|
|
|
345
|
-
if (
|
|
346
|
-
throw new BridgePathError("invalid", `download of ${
|
|
438
|
+
if (declared !== undefined && declared > DOWNLOAD_LIMIT) {
|
|
439
|
+
throw new BridgePathError("invalid", `download of ${declared} bytes exceeds the limit`);
|
|
347
440
|
}
|
|
348
441
|
|
|
442
|
+
const verified = downloadOptions.digest !== undefined;
|
|
443
|
+
const limit = verified ? DOWNLOAD_LIMIT : (declared ?? DOWNLOAD_LIMIT);
|
|
444
|
+
|
|
349
445
|
const source = await openSource(remote, downloadOptions);
|
|
350
446
|
|
|
351
447
|
await ensureParent(path);
|
|
@@ -357,11 +453,14 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
|
|
|
357
453
|
|
|
358
454
|
const hasher = downloadOptions.digest ? new Bun.CryptoHasher(parseDigest(downloadOptions.digest).algo) : null;
|
|
359
455
|
const writer = Bun.file(staging).writer();
|
|
456
|
+
const quiet = downloadOptions.quiet === true;
|
|
360
457
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
458
|
+
if (!quiet) {
|
|
459
|
+
options.onLog("downloading", {
|
|
460
|
+
file: resolved.relative,
|
|
461
|
+
sizeBytes: totalBytes,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
365
464
|
|
|
366
465
|
let readBytes = 0;
|
|
367
466
|
|
|
@@ -380,7 +479,7 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
|
|
|
380
479
|
|
|
381
480
|
await writer.flush();
|
|
382
481
|
|
|
383
|
-
if (totalBytes !== null) {
|
|
482
|
+
if (totalBytes !== null && !quiet) {
|
|
384
483
|
const reached = Math.floor((readBytes / totalBytes) * 100);
|
|
385
484
|
|
|
386
485
|
if (reached >= milestone + MILESTONE_STEP && reached < 100) {
|
|
@@ -396,11 +495,8 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
|
|
|
396
495
|
|
|
397
496
|
await writer.end();
|
|
398
497
|
|
|
399
|
-
if (
|
|
400
|
-
throw new BridgePathError(
|
|
401
|
-
"invalid",
|
|
402
|
-
`${remote.host} returned ${readBytes} bytes, expected ${downloadOptions.sizeBytes}`,
|
|
403
|
-
);
|
|
498
|
+
if (!verified && declared !== undefined && readBytes !== declared) {
|
|
499
|
+
throw new BridgePathError("invalid", `${remote.host} returned ${readBytes} bytes, expected ${declared}`);
|
|
404
500
|
}
|
|
405
501
|
|
|
406
502
|
if (hasher && downloadOptions.digest) {
|
|
@@ -410,6 +506,14 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
|
|
|
410
506
|
throw new BridgePathError("invalid", `${remote.host} failed digest verification`);
|
|
411
507
|
}
|
|
412
508
|
}
|
|
509
|
+
|
|
510
|
+
if (verified && declared !== undefined && readBytes !== declared) {
|
|
511
|
+
options.onLog.warn("the source declared a different size, the checksum passed so we kept the file", {
|
|
512
|
+
file: resolved.relative,
|
|
513
|
+
sizeBytes: readBytes,
|
|
514
|
+
declaredBytes: declared,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
413
517
|
} catch (error) {
|
|
414
518
|
writer.end();
|
|
415
519
|
|
|
@@ -423,10 +527,12 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
|
|
|
423
527
|
await rename(staging, resolved.target);
|
|
424
528
|
await own(resolved.target);
|
|
425
529
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
530
|
+
if (!quiet) {
|
|
531
|
+
options.onLog("downloaded", {
|
|
532
|
+
file: resolved.relative,
|
|
533
|
+
sizeBytes: readBytes,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
430
536
|
},
|
|
431
537
|
|
|
432
538
|
async extract(path, destination, extractOptions: BridgeExtractOptions = {}) {
|
|
@@ -442,17 +548,18 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
|
|
|
442
548
|
|
|
443
549
|
const tree = extractOptions.tree === true;
|
|
444
550
|
const allowed = extractOptions.extensions?.map((extension) => extension.toLowerCase());
|
|
445
|
-
const
|
|
551
|
+
const selects = compileSelects(extractOptions.select, extractOptions.selects);
|
|
552
|
+
const excluded = compileGlobs(extractOptions.exclude ?? []);
|
|
446
553
|
const base = toSegments(destination);
|
|
447
554
|
const target = await ensureDirectory(destination);
|
|
448
|
-
const
|
|
555
|
+
const archive = await openZip(resolved.target, tree ? ZIP_TREE_ENTRY_LIMIT : ZIP_ENTRY_LIMIT);
|
|
449
556
|
const directories = new Set<string>();
|
|
450
557
|
const written: string[] = [];
|
|
451
558
|
|
|
452
|
-
for (const entry of
|
|
453
|
-
const selected = selectedName(entry.name,
|
|
559
|
+
for (const entry of archive.entries) {
|
|
560
|
+
const selected = selectedName(entry.name, selects);
|
|
454
561
|
|
|
455
|
-
if (selected === null) {
|
|
562
|
+
if (selected === null || matchesAny(selected, excluded)) {
|
|
456
563
|
continue;
|
|
457
564
|
}
|
|
458
565
|
|
|
@@ -495,13 +602,13 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
|
|
|
495
602
|
...segments,
|
|
496
603
|
].join("/");
|
|
497
604
|
|
|
498
|
-
await Bun.write(`${target}/${segments.join("/")}`, entry
|
|
605
|
+
await Bun.write(`${target}/${segments.join("/")}`, await archive.read(entry));
|
|
499
606
|
await own(`${target}/${segments.join("/")}`);
|
|
500
607
|
|
|
501
608
|
written.push(relative);
|
|
502
609
|
}
|
|
503
610
|
|
|
504
|
-
if (written.length === 0) {
|
|
611
|
+
if (written.length === 0 && extractOptions.selects === undefined) {
|
|
505
612
|
throw new BridgePathError("invalid", `archive "${path}" contained no matching files`);
|
|
506
613
|
}
|
|
507
614
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { BridgeDesired, BridgeHoldReason, BridgeRuntime } from "../core";
|
|
3
|
+
import { decideInstall, recountHold } from "./bridgeInstall";
|
|
4
|
+
|
|
5
|
+
describe("decideInstall", () => {
|
|
6
|
+
test("starts the game when the install returns nothing and the server wants to run", () => {
|
|
7
|
+
expect(decideInstall(undefined, BridgeDesired.Running)).toEqual({
|
|
8
|
+
hold: null,
|
|
9
|
+
runtime: BridgeRuntime.Starting,
|
|
10
|
+
start: true,
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("stays stopped when the install returns nothing and the server wants to stay down", () => {
|
|
15
|
+
expect(decideInstall(undefined, BridgeDesired.Stopped)).toEqual({
|
|
16
|
+
hold: null,
|
|
17
|
+
runtime: BridgeRuntime.Stopped,
|
|
18
|
+
start: false,
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("starts the game when the install answers with no hold", () => {
|
|
23
|
+
expect(
|
|
24
|
+
decideInstall(
|
|
25
|
+
{
|
|
26
|
+
hold: null,
|
|
27
|
+
},
|
|
28
|
+
BridgeDesired.Running,
|
|
29
|
+
),
|
|
30
|
+
).toEqual({
|
|
31
|
+
hold: null,
|
|
32
|
+
runtime: BridgeRuntime.Starting,
|
|
33
|
+
start: true,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("holds the game stopped when the install answers with a hold", () => {
|
|
38
|
+
const hold = {
|
|
39
|
+
reason: BridgeHoldReason.PendingFiles,
|
|
40
|
+
count: 2,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
expect(
|
|
44
|
+
decideInstall(
|
|
45
|
+
{
|
|
46
|
+
hold,
|
|
47
|
+
},
|
|
48
|
+
BridgeDesired.Running,
|
|
49
|
+
),
|
|
50
|
+
).toEqual({
|
|
51
|
+
hold,
|
|
52
|
+
runtime: BridgeRuntime.Stopped,
|
|
53
|
+
start: false,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("keeps the hold when the server was already stopped", () => {
|
|
58
|
+
const hold = {
|
|
59
|
+
reason: BridgeHoldReason.PendingFiles,
|
|
60
|
+
count: 1,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
expect(
|
|
64
|
+
decideInstall(
|
|
65
|
+
{
|
|
66
|
+
hold,
|
|
67
|
+
},
|
|
68
|
+
BridgeDesired.Stopped,
|
|
69
|
+
),
|
|
70
|
+
).toEqual({
|
|
71
|
+
hold,
|
|
72
|
+
runtime: BridgeRuntime.Stopped,
|
|
73
|
+
start: false,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("recountHold", () => {
|
|
79
|
+
test("leaves a driver without a hold alone", () => {
|
|
80
|
+
expect(recountHold(null, 3)).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("moves the count of a pending-files hold to what the driver now lists", () => {
|
|
84
|
+
expect(
|
|
85
|
+
recountHold(
|
|
86
|
+
{
|
|
87
|
+
reason: BridgeHoldReason.PendingFiles,
|
|
88
|
+
count: 3,
|
|
89
|
+
},
|
|
90
|
+
1,
|
|
91
|
+
),
|
|
92
|
+
).toEqual({
|
|
93
|
+
reason: BridgeHoldReason.PendingFiles,
|
|
94
|
+
count: 1,
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("keeps the same hold when the count did not move", () => {
|
|
99
|
+
const hold = {
|
|
100
|
+
reason: BridgeHoldReason.PendingFiles,
|
|
101
|
+
count: 2,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
expect(recountHold(hold, 2)).toBe(hold);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("clears the hold when nothing is pending any more", () => {
|
|
108
|
+
expect(
|
|
109
|
+
recountHold(
|
|
110
|
+
{
|
|
111
|
+
reason: BridgeHoldReason.PendingFiles,
|
|
112
|
+
count: 1,
|
|
113
|
+
},
|
|
114
|
+
0,
|
|
115
|
+
),
|
|
116
|
+
).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BridgeDesired,
|
|
3
|
+
BridgeHoldReason,
|
|
4
|
+
type BridgeInstallHold,
|
|
5
|
+
type BridgeInstallOutcome,
|
|
6
|
+
BridgeRuntime,
|
|
7
|
+
} from "../core";
|
|
8
|
+
|
|
9
|
+
export interface BridgeInstallDecision {
|
|
10
|
+
hold: BridgeInstallHold | null;
|
|
11
|
+
runtime: BridgeRuntime;
|
|
12
|
+
start: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const decideInstall = (
|
|
16
|
+
outcome: BridgeInstallOutcome | undefined,
|
|
17
|
+
desired: BridgeDesired,
|
|
18
|
+
): BridgeInstallDecision => {
|
|
19
|
+
const hold = outcome ? outcome.hold : null;
|
|
20
|
+
|
|
21
|
+
if (hold) {
|
|
22
|
+
return {
|
|
23
|
+
hold,
|
|
24
|
+
runtime: BridgeRuntime.Stopped,
|
|
25
|
+
start: false,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const running = desired === BridgeDesired.Running;
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
hold: null,
|
|
33
|
+
runtime: running ? BridgeRuntime.Starting : BridgeRuntime.Stopped,
|
|
34
|
+
start: running,
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const recountHold = (hold: BridgeInstallHold | null, count: number): BridgeInstallHold | null => {
|
|
39
|
+
if (!hold || hold.reason !== BridgeHoldReason.PendingFiles) {
|
|
40
|
+
return hold;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (count <= 0) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (hold.count === count) {
|
|
48
|
+
return hold;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
reason: hold.reason,
|
|
53
|
+
count,
|
|
54
|
+
};
|
|
55
|
+
};
|
package/src/runtime/bridgeNet.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { BridgeNet, BridgeRequestOptions } from "../context";
|
|
2
2
|
import { BridgeNetError } from "../core";
|
|
3
|
+
import { BridgeHttpMethod } from "../protocol";
|
|
3
4
|
import { type BridgeEgress, BridgeEgressCallError, BridgeEgressUnavailableError } from "./bridgeEgress";
|
|
4
5
|
|
|
5
6
|
const REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -10,6 +11,10 @@ export const USER_AGENT = "serverk-bridge/1.0 (serverk.gg)";
|
|
|
10
11
|
|
|
11
12
|
const LINE_BREAK = /[\r\n]/;
|
|
12
13
|
|
|
14
|
+
const hasHeader = (headers: Record<string, string> | undefined, name: string) => {
|
|
15
|
+
return Object.keys(headers ?? {}).some((key) => key.toLowerCase() === name);
|
|
16
|
+
};
|
|
17
|
+
|
|
13
18
|
export const safeHeaders = (headers: Record<string, string> = {}) => {
|
|
14
19
|
const checked: Record<string, string> = {};
|
|
15
20
|
|
|
@@ -54,12 +59,24 @@ const readCapped = async (response: Response, host: string) => {
|
|
|
54
59
|
};
|
|
55
60
|
|
|
56
61
|
export const createBridgeNet = (egress: BridgeEgress): BridgeNet => {
|
|
57
|
-
const direct = async (
|
|
62
|
+
const direct = async (
|
|
63
|
+
remote: URL,
|
|
64
|
+
headers: Record<string, string>,
|
|
65
|
+
timeoutMs: number,
|
|
66
|
+
httpMethod: BridgeHttpMethod,
|
|
67
|
+
body: string | null,
|
|
68
|
+
) => {
|
|
58
69
|
const response = await fetch(remote, {
|
|
70
|
+
method: httpMethod,
|
|
59
71
|
headers: {
|
|
60
72
|
...headers,
|
|
61
73
|
"user-agent": USER_AGENT,
|
|
62
74
|
},
|
|
75
|
+
...(body === null
|
|
76
|
+
? {}
|
|
77
|
+
: {
|
|
78
|
+
body,
|
|
79
|
+
}),
|
|
63
80
|
redirect: "error",
|
|
64
81
|
signal: AbortSignal.timeout(timeoutMs),
|
|
65
82
|
});
|
|
@@ -78,11 +95,23 @@ export const createBridgeNet = (egress: BridgeEgress): BridgeNet => {
|
|
|
78
95
|
throw new BridgeNetError(null, `${remote.host} is not https`);
|
|
79
96
|
}
|
|
80
97
|
|
|
81
|
-
const
|
|
98
|
+
const httpMethod = options.httpMethod ?? BridgeHttpMethod.Get;
|
|
99
|
+
const body = httpMethod === BridgeHttpMethod.Post ? (options.body ?? null) : null;
|
|
100
|
+
|
|
101
|
+
const headers = safeHeaders({
|
|
102
|
+
...(body !== null && !hasHeader(options.headers, "content-type")
|
|
103
|
+
? {
|
|
104
|
+
"content-type": "application/json",
|
|
105
|
+
}
|
|
106
|
+
: {}),
|
|
107
|
+
...options.headers,
|
|
108
|
+
});
|
|
82
109
|
|
|
83
110
|
try {
|
|
84
111
|
const result = await egress.fetch(url, {
|
|
85
112
|
...options,
|
|
113
|
+
httpMethod,
|
|
114
|
+
body: body ?? undefined,
|
|
86
115
|
headers,
|
|
87
116
|
});
|
|
88
117
|
|
|
@@ -97,7 +126,7 @@ export const createBridgeNet = (egress: BridgeEgress): BridgeNet => {
|
|
|
97
126
|
}
|
|
98
127
|
}
|
|
99
128
|
|
|
100
|
-
return await direct(remote, headers, options.timeoutMs ?? REQUEST_TIMEOUT_MS);
|
|
129
|
+
return await direct(remote, headers, options.timeoutMs ?? REQUEST_TIMEOUT_MS, httpMethod, body);
|
|
101
130
|
};
|
|
102
131
|
|
|
103
132
|
return {
|