@serverkgg/bridge 0.0.9 → 0.0.11

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 (54) hide show
  1. package/package.json +1 -1
  2. package/src/cli/validate.test.ts +116 -1
  3. package/src/cli/validate.ts +50 -3
  4. package/src/context/bridgeContext.ts +8 -1
  5. package/src/core/bridgeKind.ts +1 -0
  6. package/src/core/bridgeProbeError.ts +16 -0
  7. package/src/core/bridgeSetup.ts +20 -0
  8. package/src/core/bridgeValue.test.ts +29 -0
  9. package/src/core/bridgeValue.ts +12 -0
  10. package/src/core/index.ts +2 -0
  11. package/src/guides/blocks.ts +8 -0
  12. package/src/guides/markdown.test.ts +18 -0
  13. package/src/guides/markdown.ts +7 -1
  14. package/src/manifest/guide.ts +1 -0
  15. package/src/manifest/manifest.test.ts +49 -2
  16. package/src/manifest/presence.ts +9 -1
  17. package/src/modules/bridge.ts +20 -0
  18. package/src/modules/bridgeDriver.ts +2 -0
  19. package/src/modules/bridgeModule.ts +7 -0
  20. package/src/panel/bridgeControl.ts +1 -0
  21. package/src/panel/bridgeDetail.ts +6 -5
  22. package/src/panel/bridgePanel.ts +2 -0
  23. package/src/probes/a2s.test.ts +71 -0
  24. package/src/probes/a2s.ts +38 -11
  25. package/src/protocol/bridgeFrame.ts +5 -0
  26. package/src/protocol/bridgeLimits.ts +10 -0
  27. package/src/protocol/bridgeManifest.ts +2 -0
  28. package/src/protocol/bridgeSetup.ts +97 -0
  29. package/src/protocol/index.ts +1 -0
  30. package/src/runtime/bridgeConfigReader.ts +12 -8
  31. package/src/runtime/bridgeContext.ts +28 -2
  32. package/src/runtime/bridgeDriver.test.ts +512 -2
  33. package/src/runtime/bridgeDriver.ts +219 -1
  34. package/src/runtime/bridgeEvents.test.ts +96 -0
  35. package/src/runtime/bridgeEvents.ts +4 -1
  36. package/src/runtime/bridgeRegistry.test.ts +38 -1
  37. package/src/runtime/bridgeRegistry.ts +14 -1
  38. package/src/runtime/bridgeSecrets.test.ts +121 -0
  39. package/src/runtime/bridgeSecrets.ts +43 -0
  40. package/src/runtime/bridgeServer.test.ts +262 -2
  41. package/src/runtime/bridgeServer.ts +17 -0
  42. package/src/runtime/bridgeSetup.test.ts +575 -0
  43. package/src/runtime/bridgeSetup.ts +303 -0
  44. package/src/runtime/bridgeSetupHandlers.test.ts +303 -0
  45. package/src/runtime/bridgeSetupHandlers.ts +71 -0
  46. package/src/runtime/bridgeSignal.test.ts +114 -0
  47. package/src/runtime/bridgeSignal.ts +31 -0
  48. package/src/runtime/bridgeSupervisor.test.ts +111 -0
  49. package/src/runtime/bridgeSupervisor.ts +37 -5
  50. package/src/runtime/serve.ts +19 -1
  51. package/src/utils/index.ts +1 -0
  52. package/src/utils/stripAnsi.ts +12 -0
  53. package/src/wire/panel.test.ts +155 -0
  54. package/src/wire/panel.ts +43 -0
@@ -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"> | Error) => {
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
- if (outcome instanceof Error) {
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 Error("a2s query timed out"));
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 Error("a2s response is malformed"));
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 Error("a2s challenge is malformed"));
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
- socket?.send(infoRequest(view.slice(5, 9)));
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 Error("a2s response has an unexpected header"));
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(error instanceof Error ? error : new Error(String(error)));
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
- created.send(infoRequest(null));
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(error instanceof Error ? error : new Error(String(error)));
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 {
@@ -38,6 +38,10 @@ export const BRIDGE_EVENTS_LIMIT = 32;
38
38
 
39
39
  export const BRIDGE_PENDING_FILES_LIMIT = 64;
40
40
 
41
+ export const BRIDGE_SETUP_STEPS_LIMIT = 8;
42
+
43
+ export const BRIDGE_SETUP_ACTIONS_LIMIT = 4;
44
+
41
45
  export const BRIDGE_ID_LENGTH_LIMIT = 32;
42
46
 
43
47
  export const BRIDGE_KEY_LENGTH_LIMIT = 64;
@@ -51,3 +55,9 @@ export const BRIDGE_PLACEHOLDER_LENGTH_LIMIT = 256;
51
55
  export const BRIDGE_STAGING_LENGTH_LIMIT = 128;
52
56
 
53
57
  export const BRIDGE_EXTENSION_LENGTH_LIMIT = 16;
58
+
59
+ export const BRIDGE_PATTERN_LENGTH_LIMIT = 256;
60
+
61
+ export const BRIDGE_SETUP_CODE_LENGTH_LIMIT = 64;
62
+
63
+ export const BRIDGE_SETUP_DETAIL_LENGTH_LIMIT = 256;
@@ -1,6 +1,7 @@
1
1
  import type { BridgeKind, BridgeStream } from "../core";
2
2
  import type { BridgePanel } from "../panel";
3
3
  import type { BridgeTerminalCommand, BridgeTerminalLevel } from "../terminal";
4
+ import type { BridgeSetupManifest } from "./bridgeSetup";
4
5
 
5
6
  export interface BridgeTerminalRuleManifest {
6
7
  match: string;
@@ -28,6 +29,7 @@ export interface BridgeManifest {
28
29
  version: string;
29
30
  panel: BridgePanel | null;
30
31
  terminal: BridgeTerminalManifest | null;
32
+ setup: BridgeSetupManifest | null;
31
33
  modules: BridgeModuleManifest[];
32
34
  events: string[];
33
35
  }
@@ -0,0 +1,97 @@
1
+ import type { BridgeSetupPromptKind, BridgeSetupStepKind, BridgeSetupStepState, LocalizedText } from "../core";
2
+ import type { GuideOpenTarget } from "../guides";
3
+
4
+ interface BridgeSetupStepBase {
5
+ id: string;
6
+ title: LocalizedText;
7
+ help?: LocalizedText;
8
+ required?: boolean;
9
+ }
10
+
11
+ export interface BridgeSetupDriverStep extends BridgeSetupStepBase {
12
+ kind: BridgeSetupStepKind.Driver;
13
+ requiresRunning?: boolean;
14
+ }
15
+
16
+ export interface BridgeSetupFormStep extends BridgeSetupStepBase {
17
+ kind: BridgeSetupStepKind.Form;
18
+ tab: string;
19
+ section: string;
20
+ fields?: string[];
21
+ }
22
+
23
+ export interface BridgeSetupOpenStep extends BridgeSetupStepBase {
24
+ kind: BridgeSetupStepKind.Open;
25
+ target: GuideOpenTarget;
26
+ }
27
+
28
+ export type BridgeSetupStep = BridgeSetupDriverStep | BridgeSetupFormStep | BridgeSetupOpenStep;
29
+
30
+ export interface BridgeSetupManifest {
31
+ steps: BridgeSetupStep[];
32
+ }
33
+
34
+ export interface BridgeSetupAction {
35
+ id: string;
36
+ label: LocalizedText;
37
+ }
38
+
39
+ export interface BridgeSetupWaitPrompt {
40
+ kind: BridgeSetupPromptKind.Wait;
41
+ message: LocalizedText;
42
+ }
43
+
44
+ export interface BridgeSetupProgressPrompt {
45
+ kind: BridgeSetupPromptKind.Progress;
46
+ message: LocalizedText;
47
+ percent: number | null;
48
+ detail: string | null;
49
+ }
50
+
51
+ export interface BridgeSetupLinkPrompt {
52
+ kind: BridgeSetupPromptKind.Link;
53
+ message: LocalizedText;
54
+ url: string;
55
+ code: string | null;
56
+ expiresAt: string | null;
57
+ actions: BridgeSetupAction[];
58
+ }
59
+
60
+ export interface BridgeSetupActionPrompt {
61
+ kind: BridgeSetupPromptKind.Action;
62
+ message: LocalizedText;
63
+ actions: BridgeSetupAction[];
64
+ }
65
+
66
+ export interface BridgeSetupFailedPrompt {
67
+ kind: BridgeSetupPromptKind.Failed;
68
+ message: LocalizedText;
69
+ actions: BridgeSetupAction[];
70
+ }
71
+
72
+ export type BridgeSetupPrompt =
73
+ | BridgeSetupActionPrompt
74
+ | BridgeSetupFailedPrompt
75
+ | BridgeSetupLinkPrompt
76
+ | BridgeSetupProgressPrompt
77
+ | BridgeSetupWaitPrompt;
78
+
79
+ export interface BridgeSetupStepStatus {
80
+ id: string;
81
+ state: BridgeSetupStepState;
82
+ }
83
+
84
+ export interface BridgeSetupRuntime {
85
+ steps: BridgeSetupStepStatus[];
86
+ prompt: BridgeSetupPrompt | null;
87
+ }
88
+
89
+ export interface BridgeSetupSummary {
90
+ step: string | null;
91
+ attention: boolean;
92
+ }
93
+
94
+ export interface BridgeSetupResult {
95
+ supported: boolean;
96
+ runtime: BridgeSetupRuntime | null;
97
+ }
@@ -10,4 +10,5 @@ export * from "./bridgeLimits";
10
10
  export * from "./bridgeLine";
11
11
  export * from "./bridgeManifest";
12
12
  export * from "./bridgePreview";
13
+ export * from "./bridgeSetup";
13
14
  export * from "./bridgeVersion";
@@ -82,14 +82,8 @@ export const statePath = (config: BridgeConfig) => {
82
82
  return `${config.run}/${BRIDGE_STATE_NAME}`;
83
83
  };
84
84
 
85
- export const readConfig = async (path: string): Promise<BridgeConfig> => {
86
- const file = Bun.file(path);
87
-
88
- if (!(await file.exists())) {
89
- throw new BridgeConfigError(`no bridge config at "${path}"`);
90
- }
91
-
92
- const parsed: unknown = JSON.parse(await file.text());
85
+ export const parseConfig = (text: string): BridgeConfig => {
86
+ const parsed: unknown = JSON.parse(text);
93
87
 
94
88
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
95
89
  throw new BridgeConfigError("bridge config must be an object");
@@ -117,3 +111,13 @@ export const readConfig = async (path: string): Promise<BridgeConfig> => {
117
111
 
118
112
  return config;
119
113
  };
114
+
115
+ export const readConfig = async (path: string): Promise<BridgeConfig> => {
116
+ const file = Bun.file(path);
117
+
118
+ if (!(await file.exists())) {
119
+ throw new BridgeConfigError(`no bridge config at "${path}"`);
120
+ }
121
+
122
+ return parseConfig(await file.text());
123
+ };
@@ -1,18 +1,22 @@
1
1
  import type { BridgeContext } from "../context";
2
2
  import type { BridgeValues } from "../core";
3
- import type { BridgeConfig } from "../protocol";
3
+ import type { BridgeConfig, BridgeSetupRuntime } from "../protocol";
4
+ import { stripAnsi } from "../utils";
4
5
  import { createBridgeCodecs } from "./bridgeCodec";
6
+ import { configPath } from "./bridgeConfigReader";
5
7
  import { createBridgeEgress } from "./bridgeEgress";
6
8
  import { createBridgeExec } from "./bridgeExec";
7
9
  import { createBridgeFiles } from "./bridgeFiles";
8
10
  import type { BridgeLeveledLogger } from "./bridgeLog";
9
11
  import { createBridgeNet } from "./bridgeNet";
10
12
  import { createBridgeProbes } from "./bridgeProbe";
13
+ import { createBridgeSecrets } from "./bridgeSecrets";
11
14
  import type { BridgeSupervisor } from "./bridgeSupervisor";
12
15
 
13
16
  export interface ContextHooks {
14
17
  onLog: BridgeLeveledLogger;
15
18
  onEmit: (event: string, payload?: BridgeValues) => void;
19
+ onSetup: (runtime: BridgeSetupRuntime | null) => void;
16
20
  }
17
21
 
18
22
  export const createContext = (
@@ -22,6 +26,8 @@ export const createContext = (
22
26
  ): BridgeContext => {
23
27
  const egress = createBridgeEgress(config);
24
28
 
29
+ const secrets = createBridgeSecrets(configPath(), config);
30
+
25
31
  const files = createBridgeFiles({
26
32
  root: config.cwd,
27
33
  uid: config.gameUid,
@@ -45,7 +51,7 @@ export const createContext = (
45
51
  },
46
52
 
47
53
  secret(key) {
48
- return config.secrets[key] ?? null;
54
+ return secrets.read(key);
49
55
  },
50
56
 
51
57
  port(key) {
@@ -84,6 +90,26 @@ export const createContext = (
84
90
  async watch(pattern, timeoutMs) {
85
91
  return await supervisor.watch(pattern, timeoutMs);
86
92
  },
93
+
94
+ follow(pattern, onMatch) {
95
+ return supervisor.subscribe((entry) => {
96
+ const match = stripAnsi(entry.line).match(pattern);
97
+
98
+ if (match) {
99
+ onMatch(match);
100
+ }
101
+ });
102
+ },
103
+ },
104
+
105
+ setup: {
106
+ report(runtime) {
107
+ hooks.onSetup(runtime);
108
+ },
109
+
110
+ clear() {
111
+ hooks.onSetup(null);
112
+ },
87
113
  },
88
114
 
89
115
  emit(event, payload) {