@serverkgg/bridge 0.0.9 → 0.0.10
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 +1 -1
- package/src/cli/validate.ts +1 -0
- package/src/context/bridgeContext.ts +8 -1
- package/src/core/bridgeKind.ts +1 -0
- package/src/core/bridgeProbeError.ts +16 -0
- package/src/core/bridgeSetup.ts +20 -0
- package/src/core/bridgeValue.test.ts +29 -0
- package/src/core/bridgeValue.ts +12 -0
- package/src/core/index.ts +2 -0
- package/src/guides/blocks.ts +8 -0
- package/src/guides/markdown.test.ts +18 -0
- package/src/guides/markdown.ts +7 -1
- package/src/manifest/manifest.test.ts +49 -2
- package/src/manifest/presence.ts +9 -1
- package/src/modules/bridge.ts +20 -0
- package/src/modules/bridgeDriver.ts +2 -0
- package/src/modules/bridgeModule.ts +7 -0
- package/src/panel/bridgeDetail.ts +5 -5
- package/src/probes/a2s.test.ts +71 -0
- package/src/probes/a2s.ts +38 -11
- package/src/protocol/bridgeFrame.ts +5 -0
- package/src/protocol/bridgeLimits.ts +2 -0
- package/src/protocol/bridgeManifest.ts +2 -0
- package/src/protocol/bridgeSetup.ts +97 -0
- package/src/protocol/index.ts +1 -0
- package/src/runtime/bridgeConfigReader.ts +12 -8
- package/src/runtime/bridgeContext.ts +28 -2
- package/src/runtime/bridgeDriver.test.ts +272 -2
- package/src/runtime/bridgeDriver.ts +82 -1
- package/src/runtime/bridgeEvents.test.ts +96 -0
- package/src/runtime/bridgeEvents.ts +4 -1
- package/src/runtime/bridgeRegistry.test.ts +38 -1
- package/src/runtime/bridgeRegistry.ts +14 -1
- package/src/runtime/bridgeSecrets.test.ts +121 -0
- package/src/runtime/bridgeSecrets.ts +43 -0
- package/src/runtime/bridgeServer.test.ts +262 -2
- package/src/runtime/bridgeServer.ts +17 -0
- package/src/runtime/bridgeSetup.test.ts +189 -0
- package/src/runtime/bridgeSetup.ts +63 -0
- package/src/runtime/bridgeSignal.test.ts +114 -0
- package/src/runtime/bridgeSignal.ts +31 -0
- package/src/runtime/bridgeSupervisor.test.ts +111 -0
- package/src/runtime/bridgeSupervisor.ts +37 -5
- package/src/runtime/serve.ts +52 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/stripAnsi.ts +12 -0
- package/src/wire/panel.test.ts +93 -0
- package/src/wire/panel.ts +40 -0
package/package.json
CHANGED
package/src/cli/validate.ts
CHANGED
|
@@ -230,6 +230,7 @@ const validateGamePackage = async (source: PackageSource, manifest: Manifest, di
|
|
|
230
230
|
` driver: ${Object.keys(driver.modules ?? {}).length} panel module(s), ${driver.panel?.tabs.length ?? 0} tab(s)`,
|
|
231
231
|
);
|
|
232
232
|
console.log(` pending: ${driver.pending ? `declared (${driver.pending.extensions.length} extension(s))` : "none"}`);
|
|
233
|
+
console.log(` setup: ${driver.setup ? `${driver.setup.steps.length} step(s)` : "none"}`);
|
|
233
234
|
reportEvents(driver);
|
|
234
235
|
reportActionTargets(driver);
|
|
235
236
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { BridgeStream, BridgeValues } from "../core";
|
|
2
|
-
import type { BridgeHttpMethod } from "../protocol";
|
|
2
|
+
import type { BridgeHttpMethod, BridgeSetupRuntime } from "../protocol";
|
|
3
3
|
|
|
4
4
|
export interface BridgeServer {
|
|
5
5
|
code: string;
|
|
@@ -121,6 +121,12 @@ export interface BridgeExecResult {
|
|
|
121
121
|
export interface BridgeLogs {
|
|
122
122
|
tail(lines: number): Promise<string[]>;
|
|
123
123
|
watch(pattern: RegExp, timeoutMs: number): Promise<RegExpMatchArray | null>;
|
|
124
|
+
follow(pattern: RegExp, onMatch: (match: RegExpMatchArray) => void): () => void;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface BridgeSetupReporter {
|
|
128
|
+
report(runtime: BridgeSetupRuntime): void;
|
|
129
|
+
clear(): void;
|
|
124
130
|
}
|
|
125
131
|
|
|
126
132
|
export interface BridgeLog {
|
|
@@ -141,6 +147,7 @@ export interface BridgeContext {
|
|
|
141
147
|
command(input: string, options?: BridgeCommandOptions): Promise<BridgeReply>;
|
|
142
148
|
exec(command: string[], options?: BridgeExecOptions): Promise<BridgeExecResult>;
|
|
143
149
|
logs: BridgeLogs;
|
|
150
|
+
setup: BridgeSetupReporter;
|
|
144
151
|
emit(event: string, payload?: BridgeValues): void;
|
|
145
152
|
log: BridgeLog;
|
|
146
153
|
}
|
package/src/core/bridgeKind.ts
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export enum BridgeProbeCode {
|
|
2
|
+
Unreachable = "unreachable",
|
|
3
|
+
TimedOut = "timed-out",
|
|
4
|
+
Malformed = "malformed",
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class BridgeProbeError extends Error {
|
|
8
|
+
readonly code: BridgeProbeCode;
|
|
9
|
+
|
|
10
|
+
constructor(code: BridgeProbeCode, message: string) {
|
|
11
|
+
super(message);
|
|
12
|
+
|
|
13
|
+
this.name = "BridgeProbeError";
|
|
14
|
+
this.code = code;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export enum BridgeSetupStepKind {
|
|
2
|
+
Driver = "driver",
|
|
3
|
+
Form = "form",
|
|
4
|
+
Open = "open",
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export enum BridgeSetupStepState {
|
|
8
|
+
Pending = "pending",
|
|
9
|
+
Active = "active",
|
|
10
|
+
Done = "done",
|
|
11
|
+
Failed = "failed",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export enum BridgeSetupPromptKind {
|
|
15
|
+
Wait = "wait",
|
|
16
|
+
Progress = "progress",
|
|
17
|
+
Link = "link",
|
|
18
|
+
Action = "action",
|
|
19
|
+
Failed = "failed",
|
|
20
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { isLocalizedText } from "./bridgeValue";
|
|
3
|
+
|
|
4
|
+
describe("isLocalizedText", () => {
|
|
5
|
+
test("recognises the shape a driver authors copy in", () => {
|
|
6
|
+
expect(
|
|
7
|
+
isLocalizedText({
|
|
8
|
+
ar: "التركيب",
|
|
9
|
+
en: "Setup",
|
|
10
|
+
}),
|
|
11
|
+
).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("leaves every other bridge value alone", () => {
|
|
15
|
+
expect(isLocalizedText("Hytale")).toBe(false);
|
|
16
|
+
expect(isLocalizedText(12)).toBe(false);
|
|
17
|
+
expect(isLocalizedText(true)).toBe(false);
|
|
18
|
+
expect(isLocalizedText(null)).toBe(false);
|
|
19
|
+
expect(isLocalizedText(undefined)).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("refuses a half-written text rather than rendering an empty language", () => {
|
|
23
|
+
expect(
|
|
24
|
+
isLocalizedText({
|
|
25
|
+
en: "Setup",
|
|
26
|
+
}),
|
|
27
|
+
).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
package/src/core/bridgeValue.ts
CHANGED
|
@@ -11,6 +11,18 @@ export interface LocalizedText {
|
|
|
11
11
|
en: string;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
export type BridgeCopy = LocalizedText | string;
|
|
15
|
+
|
|
16
|
+
export const isLocalizedText = (value: unknown): value is LocalizedText => {
|
|
17
|
+
if (typeof value !== "object" || value === null) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const text = value as Partial<LocalizedText>;
|
|
22
|
+
|
|
23
|
+
return typeof text.ar === "string" && typeof text.en === "string";
|
|
24
|
+
};
|
|
25
|
+
|
|
14
26
|
export interface BridgeOption {
|
|
15
27
|
value: string;
|
|
16
28
|
label: LocalizedText;
|
package/src/core/index.ts
CHANGED
|
@@ -6,8 +6,10 @@ export * from "./bridgeId";
|
|
|
6
6
|
export * from "./bridgeKind";
|
|
7
7
|
export * from "./bridgeNetError";
|
|
8
8
|
export * from "./bridgePort";
|
|
9
|
+
export * from "./bridgeProbeError";
|
|
9
10
|
export * from "./bridgeRuntime";
|
|
10
11
|
export * from "./bridgeSecretError";
|
|
12
|
+
export * from "./bridgeSetup";
|
|
11
13
|
export * from "./bridgeStream";
|
|
12
14
|
export * from "./bridgeUserError";
|
|
13
15
|
export * from "./bridgeValue";
|
package/src/guides/blocks.ts
CHANGED
|
@@ -13,6 +13,8 @@ export enum GuideOpenTab {
|
|
|
13
13
|
Console = "console",
|
|
14
14
|
Files = "files",
|
|
15
15
|
Backups = "backups",
|
|
16
|
+
Access = "access",
|
|
17
|
+
Setup = "setup",
|
|
16
18
|
Panel = "panel",
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -51,6 +53,12 @@ export const guideOpenTargetSchema = z.discriminatedUnion("tab", [
|
|
|
51
53
|
z.object({
|
|
52
54
|
tab: z.literal(GuideOpenTab.Backups),
|
|
53
55
|
}),
|
|
56
|
+
z.object({
|
|
57
|
+
tab: z.literal(GuideOpenTab.Access),
|
|
58
|
+
}),
|
|
59
|
+
z.object({
|
|
60
|
+
tab: z.literal(GuideOpenTab.Setup),
|
|
61
|
+
}),
|
|
54
62
|
z.object({
|
|
55
63
|
tab: z.literal(GuideOpenTab.Files),
|
|
56
64
|
path: zGuideFilePath.nullable(),
|
|
@@ -132,6 +132,8 @@ describe("directives", () => {
|
|
|
132
132
|
"@[open](backups)",
|
|
133
133
|
"@[open](files:server.properties)",
|
|
134
134
|
"@[open](files)",
|
|
135
|
+
"@[open](access)",
|
|
136
|
+
"@[open](setup)",
|
|
135
137
|
"@[open](panel:mods)",
|
|
136
138
|
].join("\n"),
|
|
137
139
|
);
|
|
@@ -163,6 +165,18 @@ describe("directives", () => {
|
|
|
163
165
|
path: null,
|
|
164
166
|
},
|
|
165
167
|
},
|
|
168
|
+
{
|
|
169
|
+
type: "open",
|
|
170
|
+
target: {
|
|
171
|
+
tab: GuideOpenTab.Access,
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
type: "open",
|
|
176
|
+
target: {
|
|
177
|
+
tab: GuideOpenTab.Setup,
|
|
178
|
+
},
|
|
179
|
+
},
|
|
166
180
|
{
|
|
167
181
|
type: "open",
|
|
168
182
|
target: {
|
|
@@ -181,6 +195,10 @@ describe("directives", () => {
|
|
|
181
195
|
expect(() => parse("@[open](nowhere)")).toThrow(/unknown open target "nowhere"/);
|
|
182
196
|
});
|
|
183
197
|
|
|
198
|
+
test("rejects an argument on a target that takes none", () => {
|
|
199
|
+
expect(() => parse("@[open](setup:sign-in)")).toThrow(/"setup" target does not take an argument/);
|
|
200
|
+
});
|
|
201
|
+
|
|
184
202
|
test("rejects a traversing file path", () => {
|
|
185
203
|
expect(() => parse("@[open](files:../../etc/passwd)")).toThrow();
|
|
186
204
|
});
|
package/src/guides/markdown.ts
CHANGED
|
@@ -312,7 +312,13 @@ const parseOpenTarget = (path: string, line: number, value: string): GuideOpenTa
|
|
|
312
312
|
fail(path, line, `"${tab}" target does not take an argument`);
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
-
if (
|
|
315
|
+
if (
|
|
316
|
+
tab !== GuideOpenTab.Overview
|
|
317
|
+
&& tab !== GuideOpenTab.Console
|
|
318
|
+
&& tab !== GuideOpenTab.Backups
|
|
319
|
+
&& tab !== GuideOpenTab.Access
|
|
320
|
+
&& tab !== GuideOpenTab.Setup
|
|
321
|
+
) {
|
|
316
322
|
fail(path, line, `unknown open target "${value}"`);
|
|
317
323
|
}
|
|
318
324
|
|
|
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import { PortJoin } from "../core";
|
|
4
4
|
import { GameCategory } from "./category";
|
|
5
5
|
import { guidePackageSchema, MANIFEST_SCHEMA_VERSION, manifestSchema, packageSchema } from "./manifest";
|
|
6
|
+
import { isServerkAvatarTemplate, PRESENCE_AVATAR_LENGTH_LIMIT } from "./presence";
|
|
6
7
|
|
|
7
8
|
const text = (value: string) => ({
|
|
8
9
|
ar: value,
|
|
@@ -626,7 +627,21 @@ describe("presence", () => {
|
|
|
626
627
|
expect(result.success && result.data.presence?.fields[0]?.inline).toBe(true);
|
|
627
628
|
});
|
|
628
629
|
|
|
629
|
-
test("
|
|
630
|
+
test("accepts a serverk avatar template", () => {
|
|
631
|
+
const result = manifestSchema.safeParse({
|
|
632
|
+
...base(),
|
|
633
|
+
presence: {
|
|
634
|
+
name: "player",
|
|
635
|
+
id: "playerId",
|
|
636
|
+
avatar: "serverk://avatars/hytale/{playerId}",
|
|
637
|
+
},
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
expect(result.success).toBe(true);
|
|
641
|
+
expect(result.success && result.data.presence?.avatar).toBe("serverk://avatars/hytale/{playerId}");
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test("refuses an avatar template that is neither https nor a serverk avatar", () => {
|
|
630
645
|
const result = manifestSchema.safeParse({
|
|
631
646
|
...base(),
|
|
632
647
|
presence: {
|
|
@@ -635,7 +650,39 @@ describe("presence", () => {
|
|
|
635
650
|
});
|
|
636
651
|
|
|
637
652
|
expect(result.success).toBe(false);
|
|
638
|
-
expect(result.error && z.prettifyError(result.error)).toContain(
|
|
653
|
+
expect(result.error && z.prettifyError(result.error)).toContain(
|
|
654
|
+
"must be an https or serverk://avatars/ url template",
|
|
655
|
+
);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test("refuses a serverk scheme that is not an avatar path", () => {
|
|
659
|
+
const result = manifestSchema.safeParse({
|
|
660
|
+
...base(),
|
|
661
|
+
presence: {
|
|
662
|
+
avatar: "serverk://heads/hytale/{playerId}",
|
|
663
|
+
},
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
expect(result.success).toBe(false);
|
|
667
|
+
expect(result.error && z.prettifyError(result.error)).toContain(
|
|
668
|
+
"must be an https or serverk://avatars/ url template",
|
|
669
|
+
);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("refuses a serverk avatar template longer than the limit", () => {
|
|
673
|
+
const result = manifestSchema.safeParse({
|
|
674
|
+
...base(),
|
|
675
|
+
presence: {
|
|
676
|
+
avatar: `serverk://avatars/hytale/${"a".repeat(PRESENCE_AVATAR_LENGTH_LIMIT)}`,
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
expect(result.success).toBe(false);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test("tells a serverk avatar template apart from an https one", () => {
|
|
684
|
+
expect(isServerkAvatarTemplate("serverk://avatars/hytale/{playerId}")).toBe(true);
|
|
685
|
+
expect(isServerkAvatarTemplate("https://mc-heads.net/avatar/{skin}/128")).toBe(false);
|
|
639
686
|
});
|
|
640
687
|
|
|
641
688
|
test("refuses a payload key that could never be a capture group", () => {
|
package/src/manifest/presence.ts
CHANGED
|
@@ -6,6 +6,14 @@ export const PRESENCE_FIELDS_LIMIT = 6;
|
|
|
6
6
|
|
|
7
7
|
export const PRESENCE_AVATAR_LENGTH_LIMIT = 300;
|
|
8
8
|
|
|
9
|
+
export const SERVERK_AVATAR_PREFIX = "serverk://avatars/";
|
|
10
|
+
|
|
11
|
+
export const PRESENCE_AVATAR_PATTERN = /^(https:\/\/|serverk:\/\/avatars\/)/;
|
|
12
|
+
|
|
13
|
+
export const isServerkAvatarTemplate = (template: string): boolean => {
|
|
14
|
+
return template.startsWith(SERVERK_AVATAR_PREFIX);
|
|
15
|
+
};
|
|
16
|
+
|
|
9
17
|
export const manifestPresenceFieldSchema = z.object({
|
|
10
18
|
key: zPayloadKey,
|
|
11
19
|
label: localizedText(32),
|
|
@@ -20,7 +28,7 @@ export const manifestPresenceSchema = z.object({
|
|
|
20
28
|
avatar: z
|
|
21
29
|
.string()
|
|
22
30
|
.max(PRESENCE_AVATAR_LENGTH_LIMIT)
|
|
23
|
-
.regex(
|
|
31
|
+
.regex(PRESENCE_AVATAR_PATTERN, "must be an https or serverk://avatars/ url template")
|
|
24
32
|
.optional(),
|
|
25
33
|
fields: z.array(manifestPresenceFieldSchema).max(PRESENCE_FIELDS_LIMIT).default([]),
|
|
26
34
|
});
|
package/src/modules/bridge.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { BridgeContext, BridgeExecResult, BridgeFileEntry, BridgeReply } from "../context";
|
|
2
2
|
import type {
|
|
3
|
+
BridgeCopy,
|
|
3
4
|
BridgeInstallHold,
|
|
4
5
|
BridgeInstallOutcome,
|
|
5
6
|
BridgeOption,
|
|
@@ -34,6 +35,15 @@ import type {
|
|
|
34
35
|
} from "../protocol/bridgeCatalog";
|
|
35
36
|
import type { BridgePendingFile } from "../protocol/bridgeFrame";
|
|
36
37
|
import type { BridgePreview } from "../protocol/bridgePreview";
|
|
38
|
+
import type {
|
|
39
|
+
BridgeSetupAction,
|
|
40
|
+
BridgeSetupManifest,
|
|
41
|
+
BridgeSetupPrompt,
|
|
42
|
+
BridgeSetupRuntime,
|
|
43
|
+
BridgeSetupStep,
|
|
44
|
+
BridgeSetupStepStatus,
|
|
45
|
+
BridgeSetupSummary,
|
|
46
|
+
} from "../protocol/bridgeSetup";
|
|
37
47
|
import type { BridgeTerminalArg, BridgeTerminalCommand, BridgeTerminalRule } from "../terminal";
|
|
38
48
|
import type { BridgeDriver } from "./bridgeDriver";
|
|
39
49
|
import type {
|
|
@@ -56,6 +66,7 @@ import type {
|
|
|
56
66
|
BridgeSample,
|
|
57
67
|
BridgeServerAction,
|
|
58
68
|
BridgeSettingsModule,
|
|
69
|
+
BridgeSetupModule,
|
|
59
70
|
BridgeTerminalModule,
|
|
60
71
|
} from "./bridgeModule";
|
|
61
72
|
|
|
@@ -72,6 +83,7 @@ export namespace Bridge {
|
|
|
72
83
|
export type CatalogQuery = BridgeCatalogQuery;
|
|
73
84
|
export type Collection<Row extends BridgeRow = BridgeRow> = BridgeCollectionModule<Row>;
|
|
74
85
|
export type Context = BridgeContext;
|
|
86
|
+
export type Copy = BridgeCopy;
|
|
75
87
|
export type Detail = BridgeDetailModule;
|
|
76
88
|
export type DetailAction = BridgeDetailAction;
|
|
77
89
|
export type DetailBadge = BridgeDetailBadge;
|
|
@@ -106,6 +118,14 @@ export namespace Bridge {
|
|
|
106
118
|
export type SectionAction = BridgeSectionAction;
|
|
107
119
|
export type ServerAction = BridgeServerAction;
|
|
108
120
|
export type Settings = BridgeSettingsModule;
|
|
121
|
+
export type Setup = BridgeSetupModule;
|
|
122
|
+
export type SetupAction = BridgeSetupAction;
|
|
123
|
+
export type SetupManifest = BridgeSetupManifest;
|
|
124
|
+
export type SetupPrompt = BridgeSetupPrompt;
|
|
125
|
+
export type SetupRuntime = BridgeSetupRuntime;
|
|
126
|
+
export type SetupStep = BridgeSetupStep;
|
|
127
|
+
export type SetupStepStatus = BridgeSetupStepStatus;
|
|
128
|
+
export type SetupSummary = BridgeSetupSummary;
|
|
109
129
|
export type Terminal = BridgeTerminalModule;
|
|
110
130
|
export type TerminalArg = BridgeTerminalArg;
|
|
111
131
|
export type TerminalCommand = BridgeTerminalCommand;
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
BridgePanelModule,
|
|
9
9
|
BridgePendingModule,
|
|
10
10
|
BridgeQueryModule,
|
|
11
|
+
BridgeSetupModule,
|
|
11
12
|
BridgeTerminalModule,
|
|
12
13
|
} from "./bridgeModule";
|
|
13
14
|
|
|
@@ -19,6 +20,7 @@ export interface BridgeDriver {
|
|
|
19
20
|
backup?: BridgeBackupModule;
|
|
20
21
|
announce?: BridgeAnnounceModule;
|
|
21
22
|
pending?: BridgePendingModule;
|
|
23
|
+
setup?: BridgeSetupModule;
|
|
22
24
|
terminal?: BridgeTerminalModule;
|
|
23
25
|
panel?: BridgePanel;
|
|
24
26
|
modules?: Record<string, BridgePanelModule>;
|
|
@@ -4,6 +4,7 @@ import type { BridgeDetail, BridgeDetailOutcome } from "../panel";
|
|
|
4
4
|
import type { BridgeCatalogEntry, BridgeCatalogPage, BridgeCatalogQuery } from "../protocol/bridgeCatalog";
|
|
5
5
|
import type { BridgeDescribeResult, BridgePendingFile } from "../protocol/bridgeFrame";
|
|
6
6
|
import type { BridgePreview } from "../protocol/bridgePreview";
|
|
7
|
+
import type { BridgeSetupStep } from "../protocol/bridgeSetup";
|
|
7
8
|
import type { BridgeTerminalCommand, BridgeTerminalRule } from "../terminal";
|
|
8
9
|
|
|
9
10
|
export type BridgeDescription = Omit<BridgeDescribeResult, "supported">;
|
|
@@ -43,6 +44,12 @@ export interface BridgePendingModule {
|
|
|
43
44
|
add(context: BridgeContext, input: string): Promise<void>;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
export interface BridgeSetupModule {
|
|
48
|
+
kind: BridgeKind.Setup;
|
|
49
|
+
steps: BridgeSetupStep[];
|
|
50
|
+
submit?(context: BridgeContext, step: string, action: string, args: BridgeValues): Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
|
|
46
53
|
export interface BridgeSample {
|
|
47
54
|
online: number | null;
|
|
48
55
|
max: number | null;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BridgeValue, LocalizedText } from "../core";
|
|
1
|
+
import type { BridgeCopy, BridgeValue, LocalizedText } from "../core";
|
|
2
2
|
|
|
3
3
|
export enum BridgeDetailTone {
|
|
4
4
|
Neutral = "neutral",
|
|
@@ -22,7 +22,7 @@ export interface BridgeDetailBadge {
|
|
|
22
22
|
export interface BridgeDetailStat {
|
|
23
23
|
key: string;
|
|
24
24
|
label: LocalizedText;
|
|
25
|
-
value: BridgeValue;
|
|
25
|
+
value: BridgeValue | LocalizedText;
|
|
26
26
|
format: BridgeDetailFormat;
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -33,9 +33,9 @@ export interface BridgeDetailLink {
|
|
|
33
33
|
|
|
34
34
|
export interface BridgeDetail {
|
|
35
35
|
id: string;
|
|
36
|
-
title:
|
|
37
|
-
subtitle:
|
|
38
|
-
description:
|
|
36
|
+
title: BridgeCopy;
|
|
37
|
+
subtitle: BridgeCopy | null;
|
|
38
|
+
description: BridgeCopy | null;
|
|
39
39
|
image: string | null;
|
|
40
40
|
badges: BridgeDetailBadge[];
|
|
41
41
|
stats: BridgeDetailStat[];
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { BridgeProbeCode, BridgeProbeError } from "../core";
|
|
3
|
+
import { probeA2s } from "./a2s";
|
|
4
|
+
|
|
5
|
+
const HOST = "127.0.0.1";
|
|
6
|
+
|
|
7
|
+
const CLOSED_PORT = 59_437;
|
|
8
|
+
|
|
9
|
+
const listen = async (reply: Uint8Array | null) => {
|
|
10
|
+
return await Bun.udpSocket({
|
|
11
|
+
hostname: HOST,
|
|
12
|
+
port: 0,
|
|
13
|
+
socket: {
|
|
14
|
+
data(socket, _data, port, address) {
|
|
15
|
+
if (reply) {
|
|
16
|
+
socket.send(reply, port, address);
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe("probeA2s", () => {
|
|
24
|
+
test("rejects with an unreachable probe error when nothing listens on the port", async () => {
|
|
25
|
+
const probe = probeA2s(HOST, CLOSED_PORT, 2000);
|
|
26
|
+
|
|
27
|
+
await expect(probe).rejects.toBeInstanceOf(BridgeProbeError);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("survives the port-unreachable the game leaves behind before it binds", async () => {
|
|
31
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
32
|
+
await probeA2s(HOST, CLOSED_PORT, 300).catch(() => null);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
expect(process.exitCode ?? 0).toBe(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("times out against a port that never answers", async () => {
|
|
39
|
+
const silent = await listen(null);
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const error = await probeA2s(HOST, silent.port, 250).catch((reason: unknown) => reason);
|
|
43
|
+
|
|
44
|
+
expect(error).toBeInstanceOf(BridgeProbeError);
|
|
45
|
+
expect((error as BridgeProbeError).code).toBe(BridgeProbeCode.TimedOut);
|
|
46
|
+
} finally {
|
|
47
|
+
silent.close();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("rejects a reply that is not an a2s frame", async () => {
|
|
52
|
+
const noisy = await listen(
|
|
53
|
+
new Uint8Array([
|
|
54
|
+
1,
|
|
55
|
+
2,
|
|
56
|
+
3,
|
|
57
|
+
4,
|
|
58
|
+
5,
|
|
59
|
+
]),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const error = await probeA2s(HOST, noisy.port, 2000).catch((reason: unknown) => reason);
|
|
64
|
+
|
|
65
|
+
expect(error).toBeInstanceOf(BridgeProbeError);
|
|
66
|
+
expect((error as BridgeProbeError).code).toBe(BridgeProbeCode.Malformed);
|
|
67
|
+
} finally {
|
|
68
|
+
noisy.close();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/probes/a2s.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { BridgeA2sInfo } from "../context";
|
|
2
|
+
import { BridgeProbeCode, BridgeProbeError } from "../core";
|
|
2
3
|
|
|
3
4
|
const SIMPLE_PREFIX = 0xff;
|
|
4
5
|
|
|
@@ -67,7 +68,19 @@ const parseInfo = (view: Uint8Array) => {
|
|
|
67
68
|
};
|
|
68
69
|
};
|
|
69
70
|
|
|
71
|
+
const probeError = (code: BridgeProbeCode, message: string, cause: unknown) => {
|
|
72
|
+
if (cause instanceof BridgeProbeError) {
|
|
73
|
+
return cause;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const detail = cause instanceof Error && cause.message.length > 0 ? `: ${cause.message}` : "";
|
|
77
|
+
|
|
78
|
+
return new BridgeProbeError(code, `${message}${detail}`);
|
|
79
|
+
};
|
|
80
|
+
|
|
70
81
|
export const probeA2s = (host: string, hostPort: number, timeoutMs: number) => {
|
|
82
|
+
const target = `${host}:${hostPort}`;
|
|
83
|
+
|
|
71
84
|
return new Promise<BridgeA2sInfo>((resolve, reject) => {
|
|
72
85
|
const startedAt = performance.now();
|
|
73
86
|
|
|
@@ -75,7 +88,7 @@ export const probeA2s = (host: string, hostPort: number, timeoutMs: number) => {
|
|
|
75
88
|
let challenged = false;
|
|
76
89
|
let settled = false;
|
|
77
90
|
|
|
78
|
-
const finish = (outcome: Omit<BridgeA2sInfo, "latencyMs"> |
|
|
91
|
+
const finish = (outcome: Omit<BridgeA2sInfo, "latencyMs"> | BridgeProbeError) => {
|
|
79
92
|
if (settled) {
|
|
80
93
|
return;
|
|
81
94
|
}
|
|
@@ -83,9 +96,12 @@ export const probeA2s = (host: string, hostPort: number, timeoutMs: number) => {
|
|
|
83
96
|
settled = true;
|
|
84
97
|
|
|
85
98
|
clearTimeout(timer);
|
|
86
|
-
socket?.close();
|
|
87
99
|
|
|
88
|
-
|
|
100
|
+
try {
|
|
101
|
+
socket?.close();
|
|
102
|
+
} catch {}
|
|
103
|
+
|
|
104
|
+
if (outcome instanceof BridgeProbeError) {
|
|
89
105
|
reject(outcome);
|
|
90
106
|
|
|
91
107
|
return;
|
|
@@ -98,7 +114,7 @@ export const probeA2s = (host: string, hostPort: number, timeoutMs: number) => {
|
|
|
98
114
|
};
|
|
99
115
|
|
|
100
116
|
const timer = setTimeout(() => {
|
|
101
|
-
finish(new
|
|
117
|
+
finish(new BridgeProbeError(BridgeProbeCode.TimedOut, `a2s query to ${target} timed out`));
|
|
102
118
|
}, timeoutMs);
|
|
103
119
|
|
|
104
120
|
const handle = (view: Uint8Array) => {
|
|
@@ -109,27 +125,31 @@ export const probeA2s = (host: string, hostPort: number, timeoutMs: number) => {
|
|
|
109
125
|
|| view[2] !== SIMPLE_PREFIX
|
|
110
126
|
|| view[3] !== SIMPLE_PREFIX
|
|
111
127
|
) {
|
|
112
|
-
finish(new
|
|
128
|
+
finish(new BridgeProbeError(BridgeProbeCode.Malformed, "a2s response is malformed"));
|
|
113
129
|
|
|
114
130
|
return;
|
|
115
131
|
}
|
|
116
132
|
|
|
117
133
|
if (view[4] === CHALLENGE_HEADER) {
|
|
118
134
|
if (challenged || view.length < 9) {
|
|
119
|
-
finish(new
|
|
135
|
+
finish(new BridgeProbeError(BridgeProbeCode.Malformed, "a2s challenge is malformed"));
|
|
120
136
|
|
|
121
137
|
return;
|
|
122
138
|
}
|
|
123
139
|
|
|
124
140
|
challenged = true;
|
|
125
141
|
|
|
126
|
-
|
|
142
|
+
try {
|
|
143
|
+
socket?.send(infoRequest(view.slice(5, 9)));
|
|
144
|
+
} catch (error) {
|
|
145
|
+
finish(probeError(BridgeProbeCode.Unreachable, `a2s challenge to ${target} could not be sent`, error));
|
|
146
|
+
}
|
|
127
147
|
|
|
128
148
|
return;
|
|
129
149
|
}
|
|
130
150
|
|
|
131
151
|
if (view[4] !== INFO_HEADER) {
|
|
132
|
-
finish(new
|
|
152
|
+
finish(new BridgeProbeError(BridgeProbeCode.Malformed, "a2s response has an unexpected header"));
|
|
133
153
|
|
|
134
154
|
return;
|
|
135
155
|
}
|
|
@@ -137,7 +157,7 @@ export const probeA2s = (host: string, hostPort: number, timeoutMs: number) => {
|
|
|
137
157
|
try {
|
|
138
158
|
finish(parseInfo(view));
|
|
139
159
|
} catch (error) {
|
|
140
|
-
finish(
|
|
160
|
+
finish(probeError(BridgeProbeCode.Malformed, `a2s response from ${target} could not be read`, error));
|
|
141
161
|
}
|
|
142
162
|
};
|
|
143
163
|
|
|
@@ -150,6 +170,9 @@ export const probeA2s = (host: string, hostPort: number, timeoutMs: number) => {
|
|
|
150
170
|
data(_socket, data) {
|
|
151
171
|
handle(data);
|
|
152
172
|
},
|
|
173
|
+
error(_socket, error) {
|
|
174
|
+
finish(probeError(BridgeProbeCode.Unreachable, `a2s query to ${target} failed`, error));
|
|
175
|
+
},
|
|
153
176
|
},
|
|
154
177
|
})
|
|
155
178
|
.then((created) => {
|
|
@@ -161,10 +184,14 @@ export const probeA2s = (host: string, hostPort: number, timeoutMs: number) => {
|
|
|
161
184
|
|
|
162
185
|
socket = created;
|
|
163
186
|
|
|
164
|
-
|
|
187
|
+
try {
|
|
188
|
+
created.send(infoRequest(null));
|
|
189
|
+
} catch (error) {
|
|
190
|
+
finish(probeError(BridgeProbeCode.Unreachable, `a2s query to ${target} could not be sent`, error));
|
|
191
|
+
}
|
|
165
192
|
})
|
|
166
193
|
.catch((error) => {
|
|
167
|
-
finish(
|
|
194
|
+
finish(probeError(BridgeProbeCode.Unreachable, `a2s socket for ${target} could not be opened`, error));
|
|
168
195
|
});
|
|
169
196
|
});
|
|
170
197
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { BridgeDesired, BridgeInstallHold, BridgeRuntime, BridgeValues, LocalizedText } from "../core";
|
|
2
|
+
import type { BridgeSetupSummary } from "./bridgeSetup";
|
|
2
3
|
|
|
3
4
|
export const BRIDGE_SOCKET_NAME = "bridge.sock";
|
|
4
5
|
|
|
@@ -24,6 +25,8 @@ export enum BridgeMethod {
|
|
|
24
25
|
Quiesce = "quiesce",
|
|
25
26
|
Release = "release",
|
|
26
27
|
Sample = "sample",
|
|
28
|
+
SetupStatus = "setupStatus",
|
|
29
|
+
SetupSubmit = "setupSubmit",
|
|
27
30
|
Start = "start",
|
|
28
31
|
Status = "status",
|
|
29
32
|
Stop = "stop",
|
|
@@ -74,7 +77,9 @@ export interface BridgeStatus {
|
|
|
74
77
|
outOfMemory: boolean;
|
|
75
78
|
restarts: number;
|
|
76
79
|
installError: string | null;
|
|
80
|
+
installErrorText: LocalizedText | null;
|
|
77
81
|
installHold: BridgeInstallHold | null;
|
|
82
|
+
setup: BridgeSetupSummary | null;
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
export interface BridgeSampleResult {
|