@serverkgg/bridge 0.1.4 → 0.1.6

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 (75) hide show
  1. package/package.json +3 -3
  2. package/src/bot/botCauses.test.ts +292 -0
  3. package/src/bot/botCauses.ts +58 -0
  4. package/src/bot/botDeploy.test.ts +260 -1
  5. package/src/bot/botDeploy.ts +125 -6
  6. package/src/bot/botState.ts +6 -0
  7. package/src/bot/detect/botFrameworks.ts +1 -0
  8. package/src/bot/detect/botInspectWarning.ts +4 -1
  9. package/src/bot/detect/botPackageManager.ts +3 -0
  10. package/src/bot/detect/corpus/alexmubarakshin-ts-telegraf-boilerplate.json +37 -0
  11. package/src/bot/detect/corpus/alsarmad-discord-quran-bot.json +40 -0
  12. package/src/bot/detect/corpus/anasty17-mirror-leech-telegram-bot.json +50 -0
  13. package/src/bot/detect/corpus/anidiotsguide-guidebot.json +36 -0
  14. package/src/bot/detect/corpus/anonymousx1025-anonxmusic.json +45 -0
  15. package/src/bot/detect/corpus/bot-base-telegram-bot-template.json +35 -0
  16. package/src/bot/detect/corpus/bracketed-templates-discordjs.json +40 -0
  17. package/src/bot/detect/corpus/cog-creators-red-discordbot.json +49 -0
  18. package/src/bot/detect/corpus/cranberriez-discord.ts-bot-template.json +35 -0
  19. package/src/bot/detect/corpus/crazycat256-nextcord-bot-template.json +38 -0
  20. package/src/bot/detect/corpus/discord-tickets-bot.json +45 -0
  21. package/src/bot/detect/corpus/edjopato-telegram-typescript-bot-template.json +37 -0
  22. package/src/bot/detect/corpus/haniali3161-bot-adhkar-telegram.json +33 -0
  23. package/src/bot/detect/corpus/hellosverre-discord-ts-template.json +34 -0
  24. package/src/bot/detect/corpus/kevinnovak-discord-bot-typescript-template.json +43 -0
  25. package/src/bot/detect/corpus/kkrypt0nn-python-discord-bot-template.json +41 -0
  26. package/src/bot/detect/corpus/kolezka-telegram-grammy-template.json +37 -0
  27. package/src/bot/detect/corpus/latand-tgbot_template_v3.json +37 -0
  28. package/src/bot/detect/corpus/mahmoudbm77-zekr-telegram-bot.json +36 -0
  29. package/src/bot/detect/corpus/mericcan41-discordjs-v14-template-ts.json +35 -0
  30. package/src/bot/detect/corpus/modmail-dev-modmail.json +52 -0
  31. package/src/bot/detect/corpus/nezdemkovski-telegram-bot-boilerplate.json +39 -0
  32. package/src/bot/detect/corpus/notchum-discord-bot-template.json +39 -0
  33. package/src/bot/detect/corpus/python-discord-bot.json +44 -0
  34. package/src/bot/detect/corpus/python-telegram-bot-rules-bot.json +39 -0
  35. package/src/bot/detect/corpus/rajdave69-pycord-bot-template.json +31 -0
  36. package/src/bot/detect/corpus/rapptz-robodanny.json +36 -0
  37. package/src/bot/detect/corpus/ry2x-typescript-discord.js-v.14-template.json +35 -0
  38. package/src/bot/detect/corpus/saiteja-madha-discord-js-bot.json +45 -0
  39. package/src/bot/detect/corpus/seegreatness-discord.js-for-heroku.json +37 -0
  40. package/src/bot/detect/corpus/serverk-discord-node.json +36 -0
  41. package/src/bot/detect/corpus/serverk-discord-python.json +35 -0
  42. package/src/bot/detect/corpus/serverk-telegram-node.json +36 -0
  43. package/src/bot/detect/corpus/serverk-telegram-python.json +35 -0
  44. package/src/bot/detect/corpus/shuruhatik-quran-radio-bot-discord.json +33 -0
  45. package/src/bot/detect/corpus/skyra-project-skyra.json +46 -0
  46. package/src/bot/detect/corpus/tgbot-collection-ytdlbot.json +41 -0
  47. package/src/bot/detect/corpus/zeriodev-music-bot.json +38 -0
  48. package/src/bot/detect/detect.corpus.test.ts +77 -0
  49. package/src/bot/detect/detect.test.ts +443 -17
  50. package/src/bot/detect/detect.ts +375 -34
  51. package/src/bot/detect/detectResult.ts +4 -0
  52. package/src/bot/detect/detectTable.test.ts +22 -6
  53. package/src/bot/detect/detectTable.ts +187 -19
  54. package/src/bot/detect/detectedVersion.ts +5 -0
  55. package/src/bot/detect/envExample.test.ts +55 -1
  56. package/src/bot/detect/envExample.ts +14 -0
  57. package/src/bot/detect/index.ts +1 -0
  58. package/src/bot/detect/keys.test.ts +32 -1
  59. package/src/bot/detect/keys.ts +15 -0
  60. package/src/bot/detect/serverkJson.test.ts +2 -0
  61. package/src/bot/detect/serverkJson.ts +4 -1
  62. package/src/bot/index.ts +1 -0
  63. package/src/manifest/manifest.ts +2 -1
  64. package/src/modules/bridge.ts +2 -0
  65. package/src/modules/bridgeModule.ts +5 -0
  66. package/src/probes/minecraftPing.test.ts +2 -1
  67. package/src/protocol/bridgeCrash.ts +49 -0
  68. package/src/protocol/bridgeEvents.ts +9 -0
  69. package/src/rcon/rconClient.test.ts +3 -2
  70. package/src/runtime/bridgeDriver.test.ts +45 -0
  71. package/src/runtime/bridgeDriver.ts +25 -0
  72. package/src/runtime/bridgeEvents.ts +2 -0
  73. package/src/runtime/bridgeSupervisor.test.ts +163 -3
  74. package/src/runtime/bridgeSupervisor.ts +42 -1
  75. package/src/runtime/serve.ts +26 -5
@@ -19,6 +19,7 @@ import { createRconWriter, type RconWriter } from "./rconWriter";
19
19
  const HOST = "127.0.0.1";
20
20
 
21
21
  const CLOSED_PORT = 59_433;
22
+ const CLOSED_HOST = "::1";
22
23
 
23
24
  const PASSWORD = "kX7-forbidden-in-errors";
24
25
 
@@ -1005,7 +1006,7 @@ describe("RconClient failures", () => {
1005
1006
 
1006
1007
  it("reports a port nothing listens on", async () => {
1007
1008
  const client = new RconClient({
1008
- host: HOST,
1009
+ host: CLOSED_HOST,
1009
1010
  port: CLOSED_PORT,
1010
1011
  password: PASSWORD,
1011
1012
  timeoutMs: 1000,
@@ -1475,7 +1476,7 @@ describe("RconClient secrecy", () => {
1475
1476
  });
1476
1477
 
1477
1478
  const unreachable = new RconClient({
1478
- host: HOST,
1479
+ host: CLOSED_HOST,
1479
1480
  port: CLOSED_PORT,
1480
1481
  password: PASSWORD,
1481
1482
  timeoutMs: 500,
@@ -1899,6 +1899,51 @@ describe("the lifecycle module", () => {
1899
1899
  }
1900
1900
  });
1901
1901
 
1902
+ it("accepts a grace from one second to ten minutes and a stable line", () => {
1903
+ for (const graceSeconds of [
1904
+ 1,
1905
+ 20,
1906
+ 600,
1907
+ ]) {
1908
+ expect(() =>
1909
+ validateDriver(
1910
+ lifecycleDriver({
1911
+ graceSeconds,
1912
+ stableLine: /logged in as/i,
1913
+ }),
1914
+ ),
1915
+ ).not.toThrow();
1916
+ }
1917
+ });
1918
+
1919
+ it("refuses a grace outside one second to ten minutes", () => {
1920
+ for (const graceSeconds of [
1921
+ 0,
1922
+ 601,
1923
+ 2.5,
1924
+ ]) {
1925
+ expect(() =>
1926
+ validateDriver(
1927
+ lifecycleDriver({
1928
+ graceSeconds,
1929
+ }),
1930
+ ),
1931
+ ).toThrow(
1932
+ `the lifecycle module declares a grace of ${graceSeconds} seconds — it must be a whole number from 1 to 600`,
1933
+ );
1934
+ }
1935
+ });
1936
+
1937
+ it("refuses a stable line that is not a regular expression", () => {
1938
+ expect(() =>
1939
+ validateDriver(
1940
+ lifecycleDriver({
1941
+ stableLine: "logged in as",
1942
+ }),
1943
+ ),
1944
+ ).toThrow("the lifecycle module declares a stable line that is not a regular expression");
1945
+ });
1946
+
1902
1947
  it("accepts each lifecycle subject the bridge names", () => {
1903
1948
  for (const subject of Object.values(BridgeLifecycleSubject)) {
1904
1949
  expect(() =>
@@ -62,6 +62,10 @@ const READY_TIMEOUT_MIN_SECONDS = 1;
62
62
 
63
63
  const READY_TIMEOUT_MAX_SECONDS = 3600;
64
64
 
65
+ const GRACE_MIN_SECONDS = 1;
66
+
67
+ const GRACE_MAX_SECONDS = 600;
68
+
65
69
  const RESERVED_MODULE_IDS = new Set([
66
70
  "announce",
67
71
  "backup",
@@ -635,6 +639,27 @@ const validateLifecycle = (driver: BridgeDriver) => {
635
639
  );
636
640
  }
637
641
 
642
+ const graceSeconds: unknown = driver.lifecycle.graceSeconds;
643
+
644
+ if (
645
+ graceSeconds !== undefined
646
+ && !(
647
+ Number.isInteger(graceSeconds)
648
+ && (graceSeconds as number) >= GRACE_MIN_SECONDS
649
+ && (graceSeconds as number) <= GRACE_MAX_SECONDS
650
+ )
651
+ ) {
652
+ fail(
653
+ `the lifecycle module declares a grace of ${String(graceSeconds)} seconds — it must be a whole number from ${GRACE_MIN_SECONDS} to ${GRACE_MAX_SECONDS}`,
654
+ );
655
+ }
656
+
657
+ const stableLine: unknown = driver.lifecycle.stableLine;
658
+
659
+ if (stableLine !== undefined && !(stableLine instanceof RegExp)) {
660
+ fail("the lifecycle module declares a stable line that is not a regular expression");
661
+ }
662
+
638
663
  const subject: unknown = driver.lifecycle.subject;
639
664
 
640
665
  if (subject !== undefined && !isBridgeLifecycleSubject(subject)) {
@@ -111,6 +111,8 @@ export const createEventRing = (events: BridgeEventsModule | undefined, supervis
111
111
  push(BridgeEventName.ServerCrashed, payload);
112
112
 
113
113
  evidence.clear();
114
+
115
+ return cause;
114
116
  },
115
117
 
116
118
  discard() {
@@ -1,12 +1,14 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
2
2
  import { tmpdir } from "node:os";
3
- import { BridgeRuntime } from "../core";
3
+ import { BOT_CRASH_EVIDENCE, LOGIN_LINE } from "../bot";
4
+ import { BridgeKind, BridgeRuntime } from "../core";
4
5
  import { BridgeLifecycleSubject, BridgeStopSignal } from "../modules";
5
- import { BridgeCrashPhase, type BridgeCrashRecord } from "../protocol";
6
+ import { BridgeCrashCause, BridgeCrashPhase, type BridgeCrashRecord } from "../protocol";
7
+ import { createEventRing } from "./bridgeEvents";
6
8
  import type { BridgeLeveledLogger } from "./bridgeLog";
7
9
  import { createBridgeRedaction, REDACTED } from "./bridgeRedaction";
8
10
  import { readProcessGroup } from "./bridgeSignal";
9
- import { type BridgeSupervisor, createSupervisor } from "./bridgeSupervisor";
11
+ import { type BridgeSupervisor, createSupervisor, type SupervisorOptions } from "./bridgeSupervisor";
10
12
 
11
13
  const CHILD_PATTERN = /child=(?<pid>\d+)/;
12
14
 
@@ -1277,3 +1279,161 @@ describe("the lifecycle subject", () => {
1277
1279
  await expect(supervisor.start()).rejects.toThrow('the bot environment names "BAD-KEY"');
1278
1280
  });
1279
1281
  });
1282
+
1283
+ const GRACE_SECONDS = 1;
1284
+
1285
+ const READY_THEN_ALIVE = "echo ready; exec sleep 300";
1286
+
1287
+ const READY_THEN_LOGIN = "echo ready; sleep 0.2; echo 'Logged in as serverk-bot#0001'; exec sleep 300";
1288
+
1289
+ const READY_THEN_TOKEN_CRASH =
1290
+ "echo ready; sleep 0.3; echo 'Error [TokenInvalid]: An invalid token was provided.' >&2; exit 1";
1291
+
1292
+ const graceSupervisor = (script: string, grace: Partial<Pick<SupervisorOptions, "graceSeconds" | "stableLine">>) => {
1293
+ const records: BridgeCrashRecord[] = [];
1294
+ const causes: BridgeCrashCause[] = [];
1295
+
1296
+ let stables = 0;
1297
+
1298
+ const supervisor: BridgeSupervisor = createSupervisor({
1299
+ cwd: tmpdir(),
1300
+ uid: process.getuid?.() ?? 0,
1301
+ gid: process.getgid?.() ?? 0,
1302
+ stopTimeoutSeconds: 1,
1303
+ ready: READY,
1304
+ ...grace,
1305
+ onLog: silent,
1306
+ resolveCommand: async () => {
1307
+ return [
1308
+ "bash",
1309
+ "-c",
1310
+ script,
1311
+ ];
1312
+ },
1313
+ onReady() {
1314
+ ring.discard();
1315
+ },
1316
+ onStable() {
1317
+ stables += 1;
1318
+ },
1319
+ onExit(record) {
1320
+ records.push(record);
1321
+ causes.push(ring.crash(record));
1322
+ },
1323
+ });
1324
+
1325
+ const ring = createEventRing(
1326
+ {
1327
+ kind: BridgeKind.Events,
1328
+ patterns: [
1329
+ ...BOT_CRASH_EVIDENCE,
1330
+ ],
1331
+ },
1332
+ supervisor,
1333
+ );
1334
+
1335
+ return {
1336
+ supervisor,
1337
+ records,
1338
+ causes,
1339
+ stables: () => stables,
1340
+ };
1341
+ };
1342
+
1343
+ describe("the grace window", () => {
1344
+ it("marks a driver from an older bridge that declares no grace stable at its ready line", async () => {
1345
+ const { supervisor, stables } = graceSupervisor(READY_THEN_ALIVE, {});
1346
+
1347
+ await supervisor.start();
1348
+
1349
+ expect(await waitFor(() => stables() === 1, 5000)).toBe(true);
1350
+ expect(supervisor.runtime).toBe(BridgeRuntime.Running);
1351
+
1352
+ await supervisor.stop();
1353
+ }, 30_000);
1354
+
1355
+ it("holds a bot that printed no login line at booting until the grace passes", async () => {
1356
+ const { supervisor, stables } = graceSupervisor(READY_THEN_ALIVE, {
1357
+ graceSeconds: GRACE_SECONDS,
1358
+ stableLine: LOGIN_LINE,
1359
+ });
1360
+
1361
+ await supervisor.start();
1362
+
1363
+ expect(await waitFor(() => supervisor.runtime === BridgeRuntime.Running, 5000)).toBe(true);
1364
+
1365
+ await Bun.sleep(400);
1366
+
1367
+ expect(stables(), "the bot was stable before the grace passed").toBe(0);
1368
+ expect(await waitFor(() => stables() === 1, 5000)).toBe(true);
1369
+
1370
+ await Bun.sleep(GRACE_SECONDS * 1000);
1371
+
1372
+ expect(stables(), "the bot was reported stable twice").toBe(1);
1373
+
1374
+ await supervisor.stop();
1375
+ }, 30_000);
1376
+
1377
+ it("marks a bot stable at its login line, before the grace passes", async () => {
1378
+ const { supervisor, stables } = graceSupervisor(READY_THEN_LOGIN, {
1379
+ graceSeconds: 30,
1380
+ stableLine: LOGIN_LINE,
1381
+ });
1382
+
1383
+ await supervisor.start();
1384
+
1385
+ expect(await waitFor(() => stables() === 1, 5000)).toBe(true);
1386
+
1387
+ await supervisor.stop();
1388
+ }, 30_000);
1389
+
1390
+ it("counts an exit inside the grace as a boot failure with the cause the bot printed", async () => {
1391
+ const { supervisor, records, causes, stables } = graceSupervisor(READY_THEN_TOKEN_CRASH, {
1392
+ graceSeconds: 30,
1393
+ stableLine: LOGIN_LINE,
1394
+ });
1395
+
1396
+ await supervisor.start();
1397
+
1398
+ expect(await waitFor(() => records.length > 0, 10_000)).toBe(true);
1399
+ expect(records.at(0)?.phase).toBe(BridgeCrashPhase.Booting);
1400
+ expect(records.at(0)?.restarting).toBe(false);
1401
+ expect(causes).toEqual([
1402
+ BridgeCrashCause.TokenInvalid,
1403
+ ]);
1404
+ expect(stables()).toBe(0);
1405
+ expect(supervisor.runtime).toBe(BridgeRuntime.Crashed);
1406
+ }, 30_000);
1407
+
1408
+ it("forgets what the install printed once the launcher hands over, so only the bot's own lines name the cause", async () => {
1409
+ const { supervisor, records, causes } = graceSupervisor(
1410
+ "echo 'gyp ERR! stack Error: not found: make'; echo ready; sleep 0.3; exit 1",
1411
+ {
1412
+ graceSeconds: 30,
1413
+ stableLine: LOGIN_LINE,
1414
+ },
1415
+ );
1416
+
1417
+ await supervisor.start();
1418
+
1419
+ expect(await waitFor(() => records.length > 0, 10_000)).toBe(true);
1420
+ expect(records.at(0)?.phase).toBe(BridgeCrashPhase.Booting);
1421
+ expect(causes).toEqual([
1422
+ BridgeCrashCause.Unknown,
1423
+ ]);
1424
+ }, 30_000);
1425
+
1426
+ it("restarts the same exit when it comes after the grace", async () => {
1427
+ const { supervisor, records } = graceSupervisor("echo ready; sleep 0.5; exit 1", {
1428
+ graceSeconds: 0.2,
1429
+ });
1430
+
1431
+ await supervisor.start();
1432
+
1433
+ expect(await waitFor(() => records.length > 0, 10_000)).toBe(true);
1434
+ expect(records.at(0)?.phase).toBe(BridgeCrashPhase.Running);
1435
+ expect(records.at(0)?.restarting).toBe(true);
1436
+
1437
+ await supervisor.stop();
1438
+ }, 30_000);
1439
+ });
@@ -46,6 +46,8 @@ export interface SupervisorOptions {
46
46
  subject?: BridgeLifecycleSubject;
47
47
  ready?: RegExp;
48
48
  failed?: RegExp;
49
+ graceSeconds?: number;
50
+ stableLine?: RegExp;
49
51
  resolveCommand: () => Promise<string[]>;
50
52
  resolveEnvironment?: () => Promise<Record<string, string>>;
51
53
  redact?: (text: string) => string;
@@ -208,6 +210,11 @@ export const createSupervisor = (options: SupervisorOptions) => {
208
210
 
209
211
  const readyTimeoutMs = (options.readyTimeoutSeconds ?? options.stopTimeoutSeconds * READY_TIMEOUT_STOP_FACTOR) * 1000;
210
212
 
213
+ const graceMs =
214
+ options.graceSeconds === undefined ? (options.stableAfterMs ?? STABLE_UPTIME_MS) : options.graceSeconds * 1000;
215
+
216
+ const deferred = options.graceSeconds !== undefined || options.stableLine !== undefined;
217
+
211
218
  const awaitingReady = () => {
212
219
  return options.readyTimeoutSeconds !== undefined && runtime === BridgeRuntime.Starting;
213
220
  };
@@ -241,6 +248,36 @@ export const createSupervisor = (options: SupervisorOptions) => {
241
248
  options.onStable?.();
242
249
  };
243
250
 
251
+ const settle = (launchStamp: number) => {
252
+ if (!deferred) {
253
+ markStable();
254
+
255
+ return;
256
+ }
257
+
258
+ const current = () => launchStamp === launchedAt && child !== null;
259
+
260
+ if (stableTimer) {
261
+ clearTimeout(stableTimer);
262
+ }
263
+
264
+ stableTimer = setTimeout(() => {
265
+ if (current()) {
266
+ markStable();
267
+ }
268
+ }, graceMs);
269
+
270
+ if (!options.stableLine) {
271
+ return;
272
+ }
273
+
274
+ void watch(options.stableLine, graceMs).then((match) => {
275
+ if (match && current()) {
276
+ markStable();
277
+ }
278
+ });
279
+ };
280
+
244
281
  const emit = (raw: SupervisorLine) => {
245
282
  const entry = options.redact
246
283
  ? {
@@ -491,6 +528,10 @@ export const createSupervisor = (options: SupervisorOptions) => {
491
528
  if (!options.ready) {
492
529
  runtime = BridgeRuntime.Running;
493
530
 
531
+ if (deferred) {
532
+ settle(launchStamp);
533
+ }
534
+
494
535
  return;
495
536
  }
496
537
 
@@ -509,7 +550,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
509
550
  options.onReady?.();
510
551
  }
511
552
 
512
- markStable();
553
+ settle(launchStamp);
513
554
 
514
555
  return;
515
556
  }
@@ -98,6 +98,14 @@ export const serve = async (driver: BridgeDriver) => {
98
98
 
99
99
  const setup = createSetupHandlers(driver, log, redact);
100
100
 
101
+ const runHook = (hook: string, run: () => Promise<void>) => {
102
+ void run().catch((error: unknown) => {
103
+ log.warn(`lifecycle ${hook} hook failed`, {
104
+ error: errorText(error),
105
+ });
106
+ });
107
+ };
108
+
101
109
  const supervisor = createSupervisor({
102
110
  cwd: config.cwd,
103
111
  uid: config.gameUid,
@@ -108,6 +116,8 @@ export const serve = async (driver: BridgeDriver) => {
108
116
  subject: driver.lifecycle.subject,
109
117
  ready: driver.lifecycle.ready,
110
118
  failed: driver.lifecycle.failed,
119
+ graceSeconds: driver.lifecycle.graceSeconds,
120
+ stableLine: driver.lifecycle.stableLine,
111
121
  redact,
112
122
  onLog: log,
113
123
  async resolveCommand() {
@@ -124,20 +134,31 @@ export const serve = async (driver: BridgeDriver) => {
124
134
  await driver.lifecycle.stop?.(context);
125
135
  },
126
136
  onReady() {
127
- void Promise.resolve(driver.lifecycle.onReady?.(context)).catch((error: unknown) => {
128
- log.warn("lifecycle ready hook failed", {
129
- error: errorText(error),
130
- });
137
+ ring.discard();
138
+
139
+ runHook("ready", async () => {
140
+ await driver.lifecycle.onReady?.(context);
131
141
  });
132
142
  },
133
143
  onStable() {
134
144
  ring.discard();
145
+
146
+ runHook("stable", async () => {
147
+ await driver.lifecycle.onStable?.(context);
148
+ });
135
149
  },
136
150
  onStopped() {
137
151
  ring.discard();
138
152
  },
139
153
  onExit(record) {
140
- ring.crash(record);
154
+ const cause = ring.crash(record);
155
+
156
+ runHook("exit", async () => {
157
+ await driver.lifecycle.onExit?.(context, {
158
+ ...record,
159
+ cause,
160
+ });
161
+ });
141
162
  },
142
163
  });
143
164