@serverkgg/bridge 0.0.21 → 0.1.1

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 (86) hide show
  1. package/package.json +3 -1
  2. package/src/bot/botDeploy.test.ts +363 -0
  3. package/src/bot/botDeploy.ts +136 -0
  4. package/src/bot/botEnvironment.test.ts +189 -0
  5. package/src/bot/botEnvironment.ts +141 -0
  6. package/src/bot/botIdentity.test.ts +251 -0
  7. package/src/bot/botIdentity.ts +140 -0
  8. package/src/bot/botPanel.test.ts +216 -0
  9. package/src/bot/botPanel.ts +121 -0
  10. package/src/bot/botState.test.ts +45 -0
  11. package/src/bot/botState.ts +54 -0
  12. package/src/bot/botTerminal.test.ts +79 -0
  13. package/src/bot/botTerminal.ts +62 -0
  14. package/src/bot/botVariables.test.ts +193 -0
  15. package/src/bot/botVariables.ts +73 -0
  16. package/src/bot/detect/botFramework.ts +23 -0
  17. package/src/bot/detect/botFrameworks.ts +198 -0
  18. package/src/bot/detect/botInspectWarning.ts +15 -0
  19. package/src/bot/detect/botPackageManager.ts +6 -0
  20. package/src/bot/detect/botPlatform.ts +10 -0
  21. package/src/bot/detect/botRuntime.ts +15 -0
  22. package/src/bot/detect/botToken.test.ts +71 -0
  23. package/src/bot/detect/botToken.ts +126 -0
  24. package/src/bot/detect/detect.test.ts +543 -0
  25. package/src/bot/detect/detect.ts +842 -0
  26. package/src/bot/detect/detectResult.ts +40 -0
  27. package/src/bot/detect/detectTable.test.ts +72 -0
  28. package/src/bot/detect/detectTable.ts +166 -0
  29. package/src/bot/detect/envExample.test.ts +29 -0
  30. package/src/bot/detect/envExample.ts +48 -0
  31. package/src/bot/detect/envKey.ts +7 -0
  32. package/src/bot/detect/envScan.test.ts +39 -0
  33. package/src/bot/detect/envScan.ts +87 -0
  34. package/src/bot/detect/index.ts +17 -0
  35. package/src/bot/detect/inferBotPlatform.test.ts +60 -0
  36. package/src/bot/detect/inferBotPlatform.ts +28 -0
  37. package/src/bot/detect/keys.test.ts +28 -0
  38. package/src/bot/detect/keys.ts +64 -0
  39. package/src/bot/detect/sensitiveKey.test.ts +91 -0
  40. package/src/bot/detect/sensitiveKey.ts +44 -0
  41. package/src/bot/detect/serverkJson.test.ts +88 -0
  42. package/src/bot/detect/serverkJson.ts +174 -0
  43. package/src/bot/index.ts +8 -0
  44. package/src/cli/detectTable.test.ts +66 -0
  45. package/src/cli/detectTable.ts +43 -0
  46. package/src/cli/main.ts +12 -4
  47. package/src/cli/renderedFile.ts +35 -0
  48. package/src/cli/schema.ts +7 -26
  49. package/src/cli/validate.test.ts +210 -2
  50. package/src/cli/validate.ts +71 -7
  51. package/src/context/bridgeContext.ts +11 -0
  52. package/src/core/bridgePort.test.ts +45 -0
  53. package/src/core/bridgePort.ts +26 -0
  54. package/src/guides/compile.ts +2 -2
  55. package/src/manifest/index.ts +1 -0
  56. package/src/manifest/kind.ts +5 -0
  57. package/src/manifest/manifest.test.ts +630 -3
  58. package/src/manifest/manifest.ts +391 -87
  59. package/src/manifest/runtime.ts +2 -2
  60. package/src/manifest/source.ts +4 -4
  61. package/src/modules/bridgeLifecycleSubject.ts +8 -0
  62. package/src/modules/bridgeModule.ts +6 -0
  63. package/src/modules/bridgeStopSignal.ts +13 -0
  64. package/src/modules/index.ts +2 -0
  65. package/src/panel/bridgeDetail.ts +2 -0
  66. package/src/protocol/bridgeConfig.ts +2 -0
  67. package/src/runtime/bridgeConfigReader.test.ts +80 -0
  68. package/src/runtime/bridgeConfigReader.ts +20 -0
  69. package/src/runtime/bridgeContext.test.ts +241 -0
  70. package/src/runtime/bridgeContext.ts +35 -9
  71. package/src/runtime/bridgeDriver.test.ts +132 -1
  72. package/src/runtime/bridgeDriver.ts +54 -1
  73. package/src/runtime/bridgeFiles.test.ts +62 -0
  74. package/src/runtime/bridgeFiles.ts +12 -2
  75. package/src/runtime/bridgeLiveConfig.ts +18 -0
  76. package/src/runtime/bridgeRedaction.test.ts +410 -0
  77. package/src/runtime/bridgeRedaction.ts +214 -0
  78. package/src/runtime/bridgeRegistry.test.ts +3 -0
  79. package/src/runtime/bridgeRegistry.ts +15 -0
  80. package/src/runtime/bridgeServer.test.ts +130 -0
  81. package/src/runtime/bridgeServer.ts +24 -7
  82. package/src/runtime/bridgeSetupHandlers.test.ts +88 -0
  83. package/src/runtime/bridgeSetupHandlers.ts +16 -6
  84. package/src/runtime/bridgeSupervisor.test.ts +670 -2
  85. package/src/runtime/bridgeSupervisor.ts +219 -67
  86. package/src/runtime/serve.ts +47 -13
@@ -1,4 +1,5 @@
1
1
  import type { BridgeCopy, BridgeValue, LocalizedText } from "../core";
2
+ import type { BridgeIcon } from "./bridgeIcon";
2
3
 
3
4
  export enum BridgeDetailTone {
4
5
  Neutral = "neutral",
@@ -38,6 +39,7 @@ export interface BridgeDetail {
38
39
  subtitle: BridgeCopy | null;
39
40
  description: BridgeCopy | null;
40
41
  image: string | null;
42
+ icon?: BridgeIcon;
41
43
  badges: BridgeDetailBadge[];
42
44
  stats: BridgeDetailStat[];
43
45
  links: BridgeDetailLink[];
@@ -4,6 +4,8 @@ export interface BridgeConfig {
4
4
  cwd: string;
5
5
  run: string;
6
6
  ports: Record<string, number>;
7
+ portCounts?: Record<string, number>;
8
+ publicAddress?: string;
7
9
  memoryMb: number;
8
10
  cpuPercent: number;
9
11
  gameUid: number;
@@ -0,0 +1,80 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { BridgeConfigError, parseConfig } from "./bridgeConfigReader";
3
+
4
+ const written = (extra: Record<string, unknown> = {}) => {
5
+ return JSON.stringify({
6
+ code: "abcdef",
7
+ token: "token",
8
+ cwd: "/home/game",
9
+ run: "/run/serverk",
10
+ ports: {
11
+ game: 19_132,
12
+ media: 20_000,
13
+ },
14
+ memoryMb: 2048,
15
+ cpuPercent: 100,
16
+ gameUid: 1000,
17
+ gameGid: 1000,
18
+ variables: {},
19
+ secrets: {},
20
+ ...extra,
21
+ });
22
+ };
23
+
24
+ describe("parseConfig", () => {
25
+ test("reads a file an older agent wrote, with no public address and no port counts", () => {
26
+ const config = parseConfig(written());
27
+
28
+ expect(config.publicAddress).toBeUndefined();
29
+ expect(config.portCounts).toBeUndefined();
30
+ expect(config.ports).toEqual({
31
+ game: 19_132,
32
+ media: 20_000,
33
+ });
34
+ });
35
+
36
+ test("reads the public address and the port counts when the agent wrote them", () => {
37
+ const config = parseConfig(
38
+ written({
39
+ publicAddress: "203.0.113.7",
40
+ portCounts: {
41
+ game: 1,
42
+ media: 32,
43
+ },
44
+ }),
45
+ );
46
+
47
+ expect(config.publicAddress).toBe("203.0.113.7");
48
+ expect(config.portCounts).toEqual({
49
+ game: 1,
50
+ media: 32,
51
+ });
52
+ });
53
+
54
+ test("ignores a malformed public address and drops non-numeric counts", () => {
55
+ const config = parseConfig(
56
+ written({
57
+ publicAddress: 7,
58
+ portCounts: {
59
+ media: "32",
60
+ game: 1,
61
+ },
62
+ }),
63
+ );
64
+
65
+ expect(config.publicAddress).toBeUndefined();
66
+ expect(config.portCounts).toEqual({
67
+ game: 1,
68
+ });
69
+ });
70
+
71
+ test("still refuses a game that would run as root", () => {
72
+ expect(() =>
73
+ parseConfig(
74
+ written({
75
+ gameUid: 0,
76
+ }),
77
+ ),
78
+ ).toThrow(BridgeConfigError);
79
+ });
80
+ });
@@ -70,6 +70,24 @@ const stringRecord = (source: Record<string, unknown>, key: string) => {
70
70
  return out;
71
71
  };
72
72
 
73
+ const optionalString = (source: Record<string, unknown>, key: string) => {
74
+ const value = source[key];
75
+
76
+ if (typeof value !== "string" || value.length === 0) {
77
+ return;
78
+ }
79
+
80
+ return value;
81
+ };
82
+
83
+ const optionalNumberRecord = (source: Record<string, unknown>, key: string) => {
84
+ if (source[key] === undefined) {
85
+ return;
86
+ }
87
+
88
+ return numberRecord(source, key);
89
+ };
90
+
73
91
  export const configPath = () => {
74
92
  return `${BRIDGE_RUN_MOUNT}/${BRIDGE_CONFIG_NAME}`;
75
93
  };
@@ -97,6 +115,8 @@ export const parseConfig = (text: string): BridgeConfig => {
97
115
  cwd: requireString(source, "cwd"),
98
116
  run: requireString(source, "run"),
99
117
  ports: numberRecord(source, "ports"),
118
+ portCounts: optionalNumberRecord(source, "portCounts"),
119
+ publicAddress: optionalString(source, "publicAddress"),
100
120
  memoryMb: requireNumber(source, "memoryMb"),
101
121
  cpuPercent: requireNumber(source, "cpuPercent"),
102
122
  gameUid: requireNumber(source, "gameUid"),
@@ -0,0 +1,241 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { BridgeConfig } from "../protocol";
6
+ import { createContext } from "./bridgeContext";
7
+ import { createBridgeLiveConfig } from "./bridgeLiveConfig";
8
+ import { log } from "./bridgeLog";
9
+ import { createSupervisor } from "./bridgeSupervisor";
10
+
11
+ describe("the context's variable maps", () => {
12
+ let directory = "";
13
+ let path = "";
14
+
15
+ const configWith = (variables: Record<string, string>, secrets: Record<string, string>): BridgeConfig => ({
16
+ code: "abcdef",
17
+ token: "token",
18
+ cwd: directory,
19
+ run: join(directory, "run"),
20
+ ports: {},
21
+ memoryMb: 512,
22
+ cpuPercent: 100,
23
+ gameUid: 1000,
24
+ gameGid: 1000,
25
+ variables,
26
+ secrets,
27
+ });
28
+
29
+ beforeEach(async () => {
30
+ directory = await mkdtemp(join(tmpdir(), "bridge-context-"));
31
+ path = join(directory, "config.json");
32
+ });
33
+
34
+ afterEach(async () => {
35
+ await rm(directory, {
36
+ force: true,
37
+ recursive: true,
38
+ });
39
+ });
40
+
41
+ test("hand a driver every variable and secret the agent wrote, and follow a rewrite", async () => {
42
+ const boot = configWith(
43
+ {
44
+ MY_VAR: "1",
45
+ },
46
+ {
47
+ DISCORD_TOKEN: "sealed",
48
+ },
49
+ );
50
+
51
+ await writeFile(path, JSON.stringify(boot));
52
+
53
+ const live = createBridgeLiveConfig(path, boot, 60_000);
54
+ const supervisor = createSupervisor({
55
+ cwd: directory,
56
+ uid: 1000,
57
+ gid: 1000,
58
+ stopTimeoutSeconds: 1,
59
+ onLog: log,
60
+ resolveCommand: async () => [
61
+ "run",
62
+ ],
63
+ });
64
+ const context = createContext(
65
+ boot,
66
+ supervisor,
67
+ {
68
+ onLog: log,
69
+ onEmit() {
70
+ return;
71
+ },
72
+ onSetup() {
73
+ return;
74
+ },
75
+ async onRestart() {
76
+ return;
77
+ },
78
+ },
79
+ live,
80
+ );
81
+
82
+ expect(context.variables()).toEqual({
83
+ MY_VAR: "1",
84
+ });
85
+ expect(context.secrets()).toEqual({
86
+ DISCORD_TOKEN: "sealed",
87
+ });
88
+
89
+ await writeFile(
90
+ path,
91
+ JSON.stringify(
92
+ configWith(
93
+ {
94
+ MY_VAR: "2",
95
+ LOG_LEVEL: "debug",
96
+ },
97
+ {},
98
+ ),
99
+ ),
100
+ );
101
+
102
+ live.reload();
103
+
104
+ expect(context.variables()).toEqual({
105
+ MY_VAR: "2",
106
+ LOG_LEVEL: "debug",
107
+ });
108
+ expect(context.secrets()).toEqual({});
109
+ expect(context.variable("LOG_LEVEL")).toBe("debug");
110
+ });
111
+ });
112
+
113
+ describe("the context's ports and address", () => {
114
+ let directory = "";
115
+ let path = "";
116
+
117
+ const configWith = (extra: Partial<BridgeConfig>): BridgeConfig => ({
118
+ code: "abcdef",
119
+ token: "token",
120
+ cwd: directory,
121
+ run: join(directory, "run"),
122
+ ports: {
123
+ game: 19_140,
124
+ media: 20_064,
125
+ },
126
+ memoryMb: 512,
127
+ cpuPercent: 100,
128
+ gameUid: 1000,
129
+ gameGid: 1000,
130
+ variables: {},
131
+ secrets: {},
132
+ ...extra,
133
+ });
134
+
135
+ const contextOf = (boot: BridgeConfig) => {
136
+ const live = createBridgeLiveConfig(path, boot, 60_000);
137
+ const supervisor = createSupervisor({
138
+ cwd: directory,
139
+ uid: 1000,
140
+ gid: 1000,
141
+ stopTimeoutSeconds: 1,
142
+ onLog: log,
143
+ resolveCommand: async () => [
144
+ "run",
145
+ ],
146
+ });
147
+
148
+ return {
149
+ live,
150
+ context: createContext(
151
+ boot,
152
+ supervisor,
153
+ {
154
+ onLog: log,
155
+ onEmit() {
156
+ return;
157
+ },
158
+ onSetup() {
159
+ return;
160
+ },
161
+ async onRestart() {
162
+ return;
163
+ },
164
+ },
165
+ live,
166
+ ),
167
+ };
168
+ };
169
+
170
+ beforeEach(async () => {
171
+ directory = await mkdtemp(join(tmpdir(), "bridge-context-ports-"));
172
+ path = join(directory, "config.json");
173
+ });
174
+
175
+ afterEach(async () => {
176
+ await rm(directory, {
177
+ force: true,
178
+ recursive: true,
179
+ });
180
+ });
181
+
182
+ test("hand a driver the block the agent published and its public address", async () => {
183
+ const boot = configWith({
184
+ portCounts: {
185
+ game: 1,
186
+ media: 32,
187
+ },
188
+ publicAddress: "203.0.113.7",
189
+ });
190
+
191
+ await writeFile(path, JSON.stringify(boot));
192
+
193
+ const { context } = contextOf(boot);
194
+
195
+ expect(context.portBlock("media")).toEqual({
196
+ first: 20_064,
197
+ count: 32,
198
+ });
199
+ expect(context.portBlock("game")).toEqual({
200
+ first: 19_140,
201
+ count: 1,
202
+ });
203
+ expect(context.server.publicAddress).toBe("203.0.113.7");
204
+ });
205
+
206
+ test("treat every port as a single one and the address as unknown for a file an older agent wrote", async () => {
207
+ const boot = configWith({});
208
+
209
+ await writeFile(path, JSON.stringify(boot));
210
+
211
+ const { context } = contextOf(boot);
212
+
213
+ expect(context.portBlock("media")).toEqual({
214
+ first: 20_064,
215
+ count: 1,
216
+ });
217
+ expect(context.server.publicAddress).toBeNull();
218
+ expect(() => context.portBlock("query")).toThrow('this game declares no port named "query"');
219
+ });
220
+
221
+ test("follow a rewrite of the public address", async () => {
222
+ const boot = configWith({});
223
+
224
+ await writeFile(path, JSON.stringify(boot));
225
+
226
+ const { context, live } = contextOf(boot);
227
+
228
+ await writeFile(
229
+ path,
230
+ JSON.stringify(
231
+ configWith({
232
+ publicAddress: "198.51.100.4",
233
+ }),
234
+ ),
235
+ );
236
+
237
+ live.reload();
238
+
239
+ expect(context.server.publicAddress).toBe("198.51.100.4");
240
+ });
241
+ });
@@ -7,7 +7,7 @@ import { configPath } from "./bridgeConfigReader";
7
7
  import { createBridgeEgress } from "./bridgeEgress";
8
8
  import { createBridgeExec } from "./bridgeExec";
9
9
  import { createBridgeFiles } from "./bridgeFiles";
10
- import { createBridgeLiveConfig } from "./bridgeLiveConfig";
10
+ import { type BridgeLiveConfig, createBridgeLiveConfig } from "./bridgeLiveConfig";
11
11
  import type { BridgeLeveledLogger } from "./bridgeLog";
12
12
  import { createBridgeNet } from "./bridgeNet";
13
13
  import { createBridgeProbes } from "./bridgeProbe";
@@ -18,17 +18,17 @@ export interface ContextHooks {
18
18
  onLog: BridgeLeveledLogger;
19
19
  onEmit: (event: string, payload?: BridgeValues) => void;
20
20
  onSetup: (runtime: BridgeSetupRuntime | null) => void;
21
+ onRestart: () => Promise<void>;
21
22
  }
22
23
 
23
24
  export const createContext = (
24
25
  config: BridgeConfig,
25
26
  supervisor: BridgeSupervisor,
26
27
  hooks: ContextHooks,
28
+ live: BridgeLiveConfig = createBridgeLiveConfig(configPath(), config),
27
29
  ): BridgeContext => {
28
30
  const egress = createBridgeEgress(config);
29
31
 
30
- const live = createBridgeLiveConfig(configPath(), config);
31
-
32
32
  const files = createBridgeFiles({
33
33
  root: config.cwd,
34
34
  uid: config.gameUid,
@@ -37,6 +37,16 @@ export const createContext = (
37
37
  onLog: hooks.onLog,
38
38
  });
39
39
 
40
+ const portOf = (key: string) => {
41
+ const value = config.ports[key];
42
+
43
+ if (value === undefined) {
44
+ throw new Error(`this game declares no port named "${key}"`);
45
+ }
46
+
47
+ return value;
48
+ };
49
+
40
50
  return {
41
51
  server: {
42
52
  code: config.code,
@@ -49,24 +59,36 @@ export const createContext = (
49
59
  get running() {
50
60
  return supervisor.running;
51
61
  },
62
+ get publicAddress() {
63
+ return live.publicAddress();
64
+ },
52
65
  },
53
66
 
54
67
  variable(key) {
55
68
  return live.variable(key);
56
69
  },
57
70
 
71
+ variables() {
72
+ return live.variables();
73
+ },
74
+
58
75
  secret(key) {
59
76
  return live.secret(key);
60
77
  },
61
78
 
62
- port(key) {
63
- const value = config.ports[key];
79
+ secrets() {
80
+ return live.secrets();
81
+ },
64
82
 
65
- if (value === undefined) {
66
- throw new Error(`this game declares no port named "${key}"`);
67
- }
83
+ port(key) {
84
+ return portOf(key);
85
+ },
68
86
 
69
- return value;
87
+ portBlock(key) {
88
+ return {
89
+ first: portOf(key),
90
+ count: config.portCounts?.[key] ?? 1,
91
+ };
70
92
  },
71
93
 
72
94
  files,
@@ -123,6 +145,10 @@ export const createContext = (
123
145
  hooks.onEmit(event, payload);
124
146
  },
125
147
 
148
+ async restart() {
149
+ await hooks.onRestart();
150
+ },
151
+
126
152
  log: Object.assign(
127
153
  (message: string, fields?: BridgeValues) => {
128
154
  hooks.onLog(message, {
@@ -2,7 +2,13 @@ import { describe, expect, test } from "bun:test";
2
2
  import { BridgeKind, BridgeSetupStepKind, PortJoin, PortProtocol } from "../core";
3
3
  import { GuideOpenTab } from "../guides";
4
4
  import type { ManifestPort, ManifestPortActivation } from "../manifest";
5
- import type { BridgeDriver, BridgePendingModule, BridgeSetupModule } from "../modules";
5
+ import {
6
+ type BridgeDriver,
7
+ BridgeLifecycleSubject,
8
+ type BridgePendingModule,
9
+ type BridgeSetupModule,
10
+ BridgeStopSignal,
11
+ } from "../modules";
6
12
  import {
7
13
  BridgeConfirm,
8
14
  BridgeControl,
@@ -235,6 +241,7 @@ const port = (activeWhen: ManifestPortActivation[]): ManifestPort => ({
235
241
  join: PortJoin.Combined,
236
242
  activeWhen,
237
243
  mirror: false,
244
+ count: 1,
238
245
  });
239
246
 
240
247
  const driverWithTabs = (tabs: number, sections: number): BridgeDriver => ({
@@ -1781,3 +1788,127 @@ describe("section placement", () => {
1781
1788
  );
1782
1789
  });
1783
1790
  });
1791
+
1792
+ describe("the lifecycle module", () => {
1793
+ const lifecycleDriver = (lifecycle: Record<string, unknown>): BridgeDriver => ({
1794
+ install: {
1795
+ kind: BridgeKind.Install,
1796
+ run: async () => {
1797
+ return;
1798
+ },
1799
+ },
1800
+ lifecycle: {
1801
+ kind: BridgeKind.Lifecycle,
1802
+ command: async () => [
1803
+ "run",
1804
+ ],
1805
+ ...lifecycle,
1806
+ },
1807
+ });
1808
+
1809
+ test("accepts an environment resolver", () => {
1810
+ expect(() =>
1811
+ validateDriver(
1812
+ lifecycleDriver({
1813
+ environment: async () => ({
1814
+ BOT_TOKEN: "token",
1815
+ }),
1816
+ }),
1817
+ ),
1818
+ ).not.toThrow();
1819
+ });
1820
+
1821
+ test("accepts a lifecycle that declares no environment", () => {
1822
+ expect(() => validateDriver(lifecycleDriver({}))).not.toThrow();
1823
+ });
1824
+
1825
+ test("accepts each stop signal the bridge sends", () => {
1826
+ for (const stopSignal of Object.values(BridgeStopSignal)) {
1827
+ expect(() =>
1828
+ validateDriver(
1829
+ lifecycleDriver({
1830
+ stopSignal,
1831
+ }),
1832
+ ),
1833
+ ).not.toThrow();
1834
+ }
1835
+ });
1836
+
1837
+ test("refuses a stop signal the bridge does not send", () => {
1838
+ expect(() =>
1839
+ validateDriver(
1840
+ lifecycleDriver({
1841
+ stopSignal: "SIGHUP",
1842
+ }),
1843
+ ),
1844
+ ).toThrow('the lifecycle module declares stop signal "SIGHUP" — the bridge sends only SIGTERM or SIGINT');
1845
+ });
1846
+
1847
+ test("accepts a ready timeout from one second to an hour", () => {
1848
+ for (const readyTimeoutSeconds of [
1849
+ 1,
1850
+ 900,
1851
+ 3600,
1852
+ ]) {
1853
+ expect(() =>
1854
+ validateDriver(
1855
+ lifecycleDriver({
1856
+ readyTimeoutSeconds,
1857
+ }),
1858
+ ),
1859
+ ).not.toThrow();
1860
+ }
1861
+ });
1862
+
1863
+ test("refuses a ready timeout outside one second to an hour", () => {
1864
+ for (const readyTimeoutSeconds of [
1865
+ 0,
1866
+ 3601,
1867
+ 1.5,
1868
+ ]) {
1869
+ expect(() =>
1870
+ validateDriver(
1871
+ lifecycleDriver({
1872
+ readyTimeoutSeconds,
1873
+ }),
1874
+ ),
1875
+ ).toThrow(
1876
+ `the lifecycle module declares a ready timeout of ${readyTimeoutSeconds} seconds — it must be a whole number from 1 to 3600`,
1877
+ );
1878
+ }
1879
+ });
1880
+
1881
+ test("accepts each lifecycle subject the bridge names", () => {
1882
+ for (const subject of Object.values(BridgeLifecycleSubject)) {
1883
+ expect(() =>
1884
+ validateDriver(
1885
+ lifecycleDriver({
1886
+ subject,
1887
+ }),
1888
+ ),
1889
+ ).not.toThrow();
1890
+ }
1891
+ });
1892
+
1893
+ test("refuses a lifecycle subject the bridge does not name", () => {
1894
+ expect(() =>
1895
+ validateDriver(
1896
+ lifecycleDriver({
1897
+ subject: "server",
1898
+ }),
1899
+ ),
1900
+ ).toThrow('the lifecycle module declares subject "server" — the bridge names only game or bot');
1901
+ });
1902
+
1903
+ test("refuses an environment that is not a function", () => {
1904
+ expect(() =>
1905
+ validateDriver(
1906
+ lifecycleDriver({
1907
+ environment: {
1908
+ BOT_TOKEN: "token",
1909
+ },
1910
+ }),
1911
+ ),
1912
+ ).toThrow("the lifecycle module declares an environment that is not a function");
1913
+ });
1914
+ });
@@ -7,7 +7,14 @@ import {
7
7
  } from "../core";
8
8
  import { GuideOpenTab } from "../guides";
9
9
  import type { ManifestPort } from "../manifest";
10
- import type { BridgeDriver, BridgePanelModule } from "../modules";
10
+ import {
11
+ type BridgeDriver,
12
+ BridgeLifecycleSubject,
13
+ type BridgePanelModule,
14
+ BridgeStopSignal,
15
+ isBridgeLifecycleSubject,
16
+ isBridgeStopSignal,
17
+ } from "../modules";
11
18
  import {
12
19
  BridgeControl,
13
20
  type BridgeField,
@@ -50,6 +57,10 @@ import { actionsOf } from "./bridgeActions";
50
57
 
51
58
  const UPLOAD_MODES = new Set<string>(Object.values(BridgeUploadMode));
52
59
 
60
+ const READY_TIMEOUT_MIN_SECONDS = 1;
61
+
62
+ const READY_TIMEOUT_MAX_SECONDS = 3600;
63
+
53
64
  const RESERVED_MODULE_IDS = new Set([
54
65
  "announce",
55
66
  "backup",
@@ -591,6 +602,47 @@ const validateSection = (modules: Record<string, BridgePanelModule>, section: Br
591
602
  }
592
603
  };
593
604
 
605
+ const validateLifecycle = (driver: BridgeDriver) => {
606
+ const environment: unknown = driver.lifecycle.environment;
607
+
608
+ if (environment !== undefined && typeof environment !== "function") {
609
+ fail(
610
+ "the lifecycle module declares an environment that is not a function — it must resolve the variables the process starts with",
611
+ );
612
+ }
613
+
614
+ const stopSignal: unknown = driver.lifecycle.stopSignal;
615
+
616
+ if (stopSignal !== undefined && !isBridgeStopSignal(stopSignal)) {
617
+ fail(
618
+ `the lifecycle module declares stop signal "${String(stopSignal)}" — the bridge sends only ${Object.values(BridgeStopSignal).join(" or ")}`,
619
+ );
620
+ }
621
+
622
+ const readyTimeoutSeconds: unknown = driver.lifecycle.readyTimeoutSeconds;
623
+
624
+ if (
625
+ readyTimeoutSeconds !== undefined
626
+ && !(
627
+ Number.isInteger(readyTimeoutSeconds)
628
+ && (readyTimeoutSeconds as number) >= READY_TIMEOUT_MIN_SECONDS
629
+ && (readyTimeoutSeconds as number) <= READY_TIMEOUT_MAX_SECONDS
630
+ )
631
+ ) {
632
+ fail(
633
+ `the lifecycle module declares a ready timeout of ${String(readyTimeoutSeconds)} seconds — it must be a whole number from ${READY_TIMEOUT_MIN_SECONDS} to ${READY_TIMEOUT_MAX_SECONDS}`,
634
+ );
635
+ }
636
+
637
+ const subject: unknown = driver.lifecycle.subject;
638
+
639
+ if (subject !== undefined && !isBridgeLifecycleSubject(subject)) {
640
+ fail(
641
+ `the lifecycle module declares subject "${String(subject)}" — the bridge names only ${Object.values(BridgeLifecycleSubject).join(" or ")}`,
642
+ );
643
+ }
644
+ };
645
+
594
646
  const validatePending = (driver: BridgeDriver) => {
595
647
  const pending = driver.pending;
596
648
 
@@ -732,6 +784,7 @@ export const validateDriver = (driver: BridgeDriver) => {
732
784
 
733
785
  countIn(emitted.size, BRIDGE_EVENTS_LIMIT, "event names", "the driver");
734
786
 
787
+ validateLifecycle(driver);
735
788
  validatePending(driver);
736
789
  validateSetup(driver);
737
790