@serverkgg/bridge 0.1.8 → 0.1.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.
@@ -1,5 +1,14 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { z } from "zod";
3
+ import {
4
+ BOT_PORT_1,
5
+ BOT_PORT_1_KEY,
6
+ BOT_PORT_2,
7
+ BOT_PORT_2_KEY,
8
+ BOT_SWITCH_ON,
9
+ BOT_WEB,
10
+ BOT_WEB_PORT_KEY,
11
+ } from "../bot/detect";
3
12
  import { PORT_BLOCK_MAX_COUNT, PortJoin, PortProtocol } from "../core";
4
13
  import { GameCategory } from "./category";
5
14
  import { ManifestExtensionKind, ManifestExtensionSource } from "./extension";
@@ -1766,6 +1775,146 @@ describe("port pairs and blocks", () => {
1766
1775
  });
1767
1776
  });
1768
1777
 
1778
+ describe("web ports", () => {
1779
+ const gamePort = () => {
1780
+ const [game] = base().container.ports;
1781
+
1782
+ if (!game) {
1783
+ throw new Error("fixture is missing its port");
1784
+ }
1785
+
1786
+ return game;
1787
+ };
1788
+
1789
+ const webPort = (key = "web", min = 31_000) => ({
1790
+ key,
1791
+ containerPort: 8080,
1792
+ protocol: PortProtocol.Tcp,
1793
+ hostRange: {
1794
+ default: min,
1795
+ min,
1796
+ max: min + 99,
1797
+ },
1798
+ web: true,
1799
+ });
1800
+
1801
+ it("reads a port from a bundle published before the web flag as not the web port", () => {
1802
+ expect(manifestSchema.parse(base()).container.ports[0]?.web).toBe(false);
1803
+ });
1804
+
1805
+ it("keeps the one web port a package declares", () => {
1806
+ const manifest = manifestSchema.parse(
1807
+ withPorts([
1808
+ gamePort(),
1809
+ webPort(),
1810
+ ]),
1811
+ );
1812
+
1813
+ expect(manifest.container.ports.map((port) => port.web)).toEqual([
1814
+ false,
1815
+ true,
1816
+ ]);
1817
+ });
1818
+
1819
+ it("refuses a second web port", () => {
1820
+ expect(
1821
+ failure(
1822
+ withPorts([
1823
+ gamePort(),
1824
+ webPort(),
1825
+ webPort("panel", 31_100),
1826
+ ]),
1827
+ ),
1828
+ ).toEqual([
1829
+ {
1830
+ path: "container.ports.2.web",
1831
+ message: 'port "web" is already the web port — a package declares at most one',
1832
+ },
1833
+ ]);
1834
+ });
1835
+
1836
+ for (const protocol of [
1837
+ PortProtocol.Udp,
1838
+ PortProtocol.TcpUdp,
1839
+ ]) {
1840
+ it(`refuses a web port carrying ${protocol}`, () => {
1841
+ expect(
1842
+ failure(
1843
+ withPorts([
1844
+ gamePort(),
1845
+ {
1846
+ ...webPort(),
1847
+ protocol,
1848
+ },
1849
+ ]),
1850
+ ),
1851
+ ).toEqual([
1852
+ {
1853
+ path: "container.ports.1.protocol",
1854
+ message: `a web port carries tcp only, not ${protocol}`,
1855
+ },
1856
+ ]);
1857
+ });
1858
+ }
1859
+
1860
+ it("refuses a web port that is a block", () => {
1861
+ expect(
1862
+ failure(
1863
+ withPorts([
1864
+ gamePort(),
1865
+ {
1866
+ ...webPort(),
1867
+ count: 2,
1868
+ },
1869
+ ]),
1870
+ ),
1871
+ ).toEqual([
1872
+ {
1873
+ path: "container.ports.1.count",
1874
+ message: "a web port is a single port, not a block of 2",
1875
+ },
1876
+ ]);
1877
+ });
1878
+
1879
+ it("refuses srv, follows and mirror on a web port", () => {
1880
+ expect(
1881
+ failure(
1882
+ withPorts([
1883
+ gamePort(),
1884
+ {
1885
+ ...webPort(),
1886
+ srv: {
1887
+ service: "_http",
1888
+ },
1889
+ follows: {
1890
+ key: "game",
1891
+ offset: 1,
1892
+ },
1893
+ mirror: true,
1894
+ },
1895
+ ]),
1896
+ ),
1897
+ ).toEqual([
1898
+ {
1899
+ path: "container.ports.1.srv",
1900
+ message: "a web port is reached only by the server's name and takes no srv",
1901
+ },
1902
+ {
1903
+ path: "container.ports.1.follows",
1904
+ message: "a web port is reached only by the server's name and takes no follows",
1905
+ },
1906
+ {
1907
+ path: "container.ports.1.mirror",
1908
+ message: "a web port is reached only by the server's name and takes no mirror",
1909
+ },
1910
+ ]);
1911
+ });
1912
+
1913
+ it("renders its description into the JSON schema", () => {
1914
+ expect(JSON.stringify(buildJsonSchema())).toContain("reached at the server's name over HTTPS");
1915
+ });
1916
+ });
1917
+
1769
1918
  const botBase = () => ({
1770
1919
  schemaVersion: MANIFEST_SCHEMA_VERSION,
1771
1920
  kind: PackageKind.Bot,
@@ -1928,6 +2077,86 @@ describe("bot packages", () => {
1928
2077
  expect(manifest.container.ports).toHaveLength(1);
1929
2078
  });
1930
2079
 
2080
+ it("keep a web port and two raw ports the author turns on", () => {
2081
+ const switched = (variable: string) => [
2082
+ {
2083
+ variable,
2084
+ values: [
2085
+ BOT_SWITCH_ON,
2086
+ ],
2087
+ },
2088
+ ];
2089
+
2090
+ const manifest = botManifestSchema.parse({
2091
+ ...botBase(),
2092
+ container: {
2093
+ ...botBase().container,
2094
+ ports: [
2095
+ {
2096
+ key: BOT_WEB_PORT_KEY,
2097
+ containerPort: 8080,
2098
+ protocol: PortProtocol.Tcp,
2099
+ hostRange: {
2100
+ default: 31_000,
2101
+ min: 31_000,
2102
+ max: 31_999,
2103
+ },
2104
+ web: true,
2105
+ activeWhen: switched(BOT_WEB),
2106
+ },
2107
+ {
2108
+ key: BOT_PORT_1_KEY,
2109
+ containerPort: 29_000,
2110
+ protocol: PortProtocol.TcpUdp,
2111
+ hostRange: {
2112
+ default: 29_000,
2113
+ min: 29_000,
2114
+ max: 29_999,
2115
+ },
2116
+ mirror: true,
2117
+ activeWhen: switched(BOT_PORT_1),
2118
+ },
2119
+ {
2120
+ key: BOT_PORT_2_KEY,
2121
+ containerPort: 30_000,
2122
+ protocol: PortProtocol.TcpUdp,
2123
+ hostRange: {
2124
+ default: 30_000,
2125
+ min: 30_000,
2126
+ max: 30_999,
2127
+ },
2128
+ mirror: true,
2129
+ activeWhen: switched(BOT_PORT_2),
2130
+ },
2131
+ ],
2132
+ },
2133
+ });
2134
+
2135
+ expect(
2136
+ manifest.container.ports.map((port) => ({
2137
+ key: port.key,
2138
+ web: port.web,
2139
+ variable: port.activeWhen[0]?.variable,
2140
+ })),
2141
+ ).toEqual([
2142
+ {
2143
+ key: BOT_WEB_PORT_KEY,
2144
+ web: true,
2145
+ variable: BOT_WEB,
2146
+ },
2147
+ {
2148
+ key: BOT_PORT_1_KEY,
2149
+ web: false,
2150
+ variable: BOT_PORT_1,
2151
+ },
2152
+ {
2153
+ key: BOT_PORT_2_KEY,
2154
+ web: false,
2155
+ variable: BOT_PORT_2,
2156
+ },
2157
+ ]);
2158
+ });
2159
+
1931
2160
  it("reach the runtime reader like a game does", () => {
1932
2161
  expect(manifestRuntimeOf(botManifestSchema.parse(botBase()))).toEqual({
1933
2162
  ...MANIFEST_RUNTIME_DEFAULTS,
@@ -103,6 +103,10 @@ const manifestPortSchema = z.object({
103
103
  description:
104
104
  "Reserves this many contiguous host ports, bound one to one from containerPort upward. A block is infrastructure, never a player address: it takes no name, hint, join, srv or follows.",
105
105
  }),
106
+ web: z.boolean().default(false).meta({
107
+ description:
108
+ "This port is the server's HTTP surface: bound inside the machine and reached at the server's name over HTTPS.",
109
+ }),
106
110
  });
107
111
 
108
112
  export type ManifestPort = z.infer<typeof manifestPortSchema>;
@@ -145,7 +149,8 @@ const gameContainerSchema = containerSchemaWith(z.array(manifestPortSchema).min(
145
149
 
146
150
  const botContainerSchema = containerSchemaWith(
147
151
  z.array(manifestPortSchema).max(MANIFEST_PORTS_LIMIT).default([]).meta({
148
- description: "A bot listens on nothing. Declare a port only when a webhook needs a public address.",
152
+ description:
153
+ "A bot listens on nothing until its author turns a port on. Gate every port with activeWhen on a variable, and mark the HTTP one web.",
149
154
  }),
150
155
  );
151
156
 
@@ -543,6 +548,108 @@ const portShapeIssues = (port: ManifestPort, index: number): CheckIssue[] => {
543
548
  return issues;
544
549
  };
545
550
 
551
+ const WEB_REFUSED_FIELDS = [
552
+ "srv",
553
+ "follows",
554
+ ] as const;
555
+
556
+ const webPortIssues = (port: ManifestPort, index: number): CheckIssue[] => {
557
+ const issues: CheckIssue[] = [];
558
+ const path = [
559
+ "container",
560
+ "ports",
561
+ index,
562
+ ];
563
+
564
+ if (port.protocol !== PortProtocol.Tcp) {
565
+ issues.push({
566
+ code: "custom",
567
+ message: `a web port carries ${PortProtocol.Tcp} only, not ${port.protocol}`,
568
+ input: port.protocol,
569
+ path: [
570
+ ...path,
571
+ "protocol",
572
+ ],
573
+ });
574
+ }
575
+
576
+ if (port.count !== 1) {
577
+ issues.push({
578
+ code: "custom",
579
+ message: `a web port is a single port, not a block of ${port.count}`,
580
+ input: port.count,
581
+ path: [
582
+ ...path,
583
+ "count",
584
+ ],
585
+ });
586
+ }
587
+
588
+ for (const field of WEB_REFUSED_FIELDS) {
589
+ if (port[field] === undefined) {
590
+ continue;
591
+ }
592
+
593
+ issues.push({
594
+ code: "custom",
595
+ message: `a web port is reached only by the server's name and takes no ${field}`,
596
+ input: port[field],
597
+ path: [
598
+ ...path,
599
+ field,
600
+ ],
601
+ });
602
+ }
603
+
604
+ if (port.mirror) {
605
+ issues.push({
606
+ code: "custom",
607
+ message: "a web port is reached only by the server's name and takes no mirror",
608
+ input: port.mirror,
609
+ path: [
610
+ ...path,
611
+ "mirror",
612
+ ],
613
+ });
614
+ }
615
+
616
+ return issues;
617
+ };
618
+
619
+ const webPortsIssues = (ports: ManifestPort[]): CheckIssue[] => {
620
+ const issues: CheckIssue[] = [];
621
+
622
+ let first: ManifestPort | null = null;
623
+
624
+ for (const [index, port] of ports.entries()) {
625
+ if (!port.web) {
626
+ continue;
627
+ }
628
+
629
+ issues.push(...webPortIssues(port, index));
630
+
631
+ if (first === null) {
632
+ first = port;
633
+
634
+ continue;
635
+ }
636
+
637
+ issues.push({
638
+ code: "custom",
639
+ message: `port "${first.key}" is already the web port — a package declares at most one`,
640
+ input: port.web,
641
+ path: [
642
+ "container",
643
+ "ports",
644
+ index,
645
+ "web",
646
+ ],
647
+ });
648
+ }
649
+
650
+ return issues;
651
+ };
652
+
546
653
  const socketBindingIssues = (ports: ManifestPort[]): CheckIssue[] => {
547
654
  const bindings = ports.reduce((total, port) => total + socketProtocolsOf(port.protocol).length, 0);
548
655
 
@@ -610,6 +717,7 @@ const portIssues = (ports: ManifestPort[]): CheckIssue[] => {
610
717
  ...ports.flatMap(portShapeIssues),
611
718
  ...socketBindingIssues(ports),
612
719
  ...followIssues(ports),
720
+ ...webPortsIssues(ports),
613
721
  ];
614
722
  };
615
723
 
@@ -17,6 +17,8 @@ export enum BridgeCrashCause {
17
17
  FfmpegMissing = "ffmpeg-missing",
18
18
  AudioopRemoved = "audioop-removed",
19
19
  RateLimited = "rate-limited",
20
+ VariableMissing = "variable-missing",
21
+ WebhookActive = "webhook-active",
20
22
  Unknown = "unknown",
21
23
  }
22
24
 
@@ -75,6 +77,10 @@ export const BRIDGE_CRASH_EVIDENCE: readonly BridgeCrashEvidence[] = [
75
77
  name: BridgeEventName.FfmpegMissing,
76
78
  cause: BridgeCrashCause.FfmpegMissing,
77
79
  },
80
+ {
81
+ name: BridgeEventName.VariableMissing,
82
+ cause: BridgeCrashCause.VariableMissing,
83
+ },
78
84
  {
79
85
  name: BridgeEventName.IntentsDisallowed,
80
86
  cause: BridgeCrashCause.IntentsDisallowed,
@@ -83,6 +89,10 @@ export const BRIDGE_CRASH_EVIDENCE: readonly BridgeCrashEvidence[] = [
83
89
  name: BridgeEventName.TokenInvalid,
84
90
  cause: BridgeCrashCause.TokenInvalid,
85
91
  },
92
+ {
93
+ name: BridgeEventName.WebhookActive,
94
+ cause: BridgeCrashCause.WebhookActive,
95
+ },
86
96
  {
87
97
  name: BridgeEventName.TokenInUse,
88
98
  cause: BridgeCrashCause.TokenInUse,
@@ -47,6 +47,8 @@ export enum BridgeEventName {
47
47
  TickLagging = "TickLagging",
48
48
  TokenInUse = "TokenInUse",
49
49
  TokenInvalid = "TokenInvalid",
50
+ VariableMissing = "VariableMissing",
51
+ WebhookActive = "WebhookActive",
50
52
  WorldActivated = "WorldActivated",
51
53
  WorldCloned = "WorldCloned",
52
54
  WorldCorrupt = "WorldCorrupt",
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from "bun:test";
2
+ import { BOT_PORT_SWITCHES, BOT_SWITCH_ON, BOT_WEB } from "../bot/detect";
2
3
  import { BridgeKind, BridgeSetupStepKind, PortJoin, PortProtocol } from "../core";
3
4
  import { GuideOpenTab } from "../guides";
4
5
  import type { ManifestPort, ManifestPortActivation } from "../manifest";
@@ -242,6 +243,7 @@ const port = (activeWhen: ManifestPortActivation[]): ManifestPort => ({
242
243
  activeWhen,
243
244
  mirror: false,
244
245
  count: 1,
246
+ web: false,
245
247
  });
246
248
 
247
249
  const driverWithTabs = (tabs: number, sections: number): BridgeDriver => ({
@@ -663,6 +665,7 @@ describe("validatePortActivation", () => {
663
665
  ]),
664
666
  ],
665
667
  driverWithVariables(),
668
+ [],
666
669
  ),
667
670
  ).not.toThrow();
668
671
  });
@@ -681,9 +684,29 @@ describe("validatePortActivation", () => {
681
684
  ]),
682
685
  ],
683
686
  driverWithVariables(),
687
+ [],
684
688
  ),
685
689
  ).toThrow('port "bedrock" activeWhen[0] names "CROSSPLAY", which no variables form, catalog or detail declares');
686
690
  });
691
+
692
+ it("accepts a variable the platform sets for the package's kind", () => {
693
+ expect(() =>
694
+ validatePortActivation(
695
+ [
696
+ port([
697
+ {
698
+ variable: BOT_WEB,
699
+ values: [
700
+ BOT_SWITCH_ON,
701
+ ],
702
+ },
703
+ ]),
704
+ ],
705
+ driverWithVariables(),
706
+ Object.values(BOT_PORT_SWITCHES),
707
+ ),
708
+ ).not.toThrow();
709
+ });
687
710
  });
688
711
 
689
712
  const driverWithDetail = (): BridgeDriver => ({
@@ -874,6 +897,7 @@ describe("detail sections", () => {
874
897
  ]),
875
898
  ],
876
899
  driverWithDetail(),
900
+ [],
877
901
  ),
878
902
  ).not.toThrow();
879
903
  });
@@ -905,12 +905,16 @@ const declaredVariables = (driver: BridgeDriver) => {
905
905
  return declared;
906
906
  };
907
907
 
908
- export const validatePortActivation = (ports: ManifestPort[], driver: BridgeDriver): void => {
908
+ export const validatePortActivation = (
909
+ ports: ManifestPort[],
910
+ driver: BridgeDriver,
911
+ platformVariables: readonly string[],
912
+ ): void => {
909
913
  const declared = declaredVariables(driver);
910
914
 
911
915
  for (const port of ports) {
912
916
  for (const [index, rule] of port.activeWhen.entries()) {
913
- if (declared.has(rule.variable)) {
917
+ if (declared.has(rule.variable) || platformVariables.includes(rule.variable)) {
914
918
  continue;
915
919
  }
916
920
 
@@ -271,6 +271,19 @@ describe("the crash record the ring builds", () => {
271
271
  });
272
272
  });
273
273
 
274
+ it("names an active webhook before the token in use when both are held", () => {
275
+ const ring = idleRing();
276
+
277
+ ring.emit(BridgeEventName.TokenInUse);
278
+ ring.emit(BridgeEventName.WebhookActive);
279
+
280
+ ring.crash(crashRecord());
281
+
282
+ expect(ring.drain(0).events.at(0)?.payload).toMatchObject({
283
+ cause: "webhook-active",
284
+ });
285
+ });
286
+
274
287
  it("lets the out of memory log pattern outrank the java version", () => {
275
288
  const ring = idleRing();
276
289