@serverkgg/bridge 0.0.17 → 0.0.19

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverkgg/bridge",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "description": "Serverk game bridge and driver runtime, wire protocol, manifest schema, and maintainer CLI",
5
5
  "homepage": "https://serverk.gg",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -39,6 +39,6 @@
39
39
  "test": "bun test --parallel"
40
40
  },
41
41
  "dependencies": {
42
- "zod": "4.5.4"
42
+ "zod": "4.6.4"
43
43
  }
44
44
  }
@@ -1,4 +1,5 @@
1
1
  export * from "./ini";
2
+ export * from "./javaProperties";
2
3
  export * from "./json";
3
4
  export * from "./properties";
4
5
  export * from "./sourceCfg";
@@ -0,0 +1,67 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { escapeJavaProperty, mergeJavaProperties, parseJavaProperties, unescapeJavaProperty } from "./javaProperties";
3
+
4
+ const ARABIC = "سيرفرك";
5
+
6
+ describe("parseJavaProperties", () => {
7
+ test("decodes the unicode escapes the java server writes", () => {
8
+ expect(parseJavaProperties("motd=\\u0633\\u064A\\u0631\\u0641\\u0631\\u0643\n")).toEqual({
9
+ motd: ARABIC,
10
+ });
11
+ });
12
+
13
+ test("decodes backslash escapes in values", () => {
14
+ expect(parseJavaProperties("resource-pack=https\\://example.com/pack.zip\nmotd=a\\tb\\\\c\n")).toEqual({
15
+ "resource-pack": "https://example.com/pack.zip",
16
+ motd: "a\tb\\c",
17
+ });
18
+ });
19
+
20
+ test("repairs a value the server stored as latin-1 bytes of utf-8", () => {
21
+ expect(parseJavaProperties("motd=ddss \\u00D9\\u008A\\u00D8\\u00AA\n")).toEqual({
22
+ motd: "ddss يت",
23
+ });
24
+ });
25
+
26
+ test("keeps plain latin-1 text that is not utf-8", () => {
27
+ expect(unescapeJavaProperty("caf\\u00E9")).toBe("café");
28
+ });
29
+
30
+ test("keeps comments and plain values", () => {
31
+ expect(parseJavaProperties("#Minecraft server properties\nmax-players=20\n")).toEqual({
32
+ "max-players": "20",
33
+ });
34
+ });
35
+ });
36
+
37
+ describe("mergeJavaProperties", () => {
38
+ test("writes non ascii text as unicode escapes", () => {
39
+ expect(
40
+ mergeJavaProperties("motd=A Minecraft Server\nmax-players=20\n", {
41
+ motd: ARABIC,
42
+ }),
43
+ ).toBe("motd=\\u0633\\u064A\\u0631\\u0641\\u0631\\u0643\nmax-players=20\n");
44
+ });
45
+
46
+ test("escapes the characters java treats as separators", () => {
47
+ expect(escapeJavaProperty("https://a.b#c=d!e")).toBe("https\\://a.b\\#c\\=d\\!e");
48
+ });
49
+
50
+ test("escapes a leading space and control characters", () => {
51
+ expect(escapeJavaProperty(" a\tb\\c")).toBe("\\ a\\tb\\\\c");
52
+ });
53
+
54
+ test("round trips astral characters", () => {
55
+ const value = "hi 🎮";
56
+
57
+ expect(unescapeJavaProperty(escapeJavaProperty(value))).toBe(value);
58
+ });
59
+
60
+ test("appends a key the file does not have yet", () => {
61
+ expect(
62
+ mergeJavaProperties("", {
63
+ motd: "hello",
64
+ }),
65
+ ).toBe("motd=hello\n");
66
+ });
67
+ });
@@ -0,0 +1,99 @@
1
+ import { mergeProperties, parseProperties } from "./properties";
2
+
3
+ const UNICODE_ESCAPE = /\\u([0-9A-Fa-f]{4})/g;
4
+
5
+ const SIMPLE_ESCAPE = /\\(.)/g;
6
+
7
+ const SIMPLE_ESCAPES: Record<string, string> = {
8
+ t: "\t",
9
+ n: "\n",
10
+ r: "\r",
11
+ f: "\f",
12
+ };
13
+
14
+ const ESCAPED_CHARACTERS: Record<string, string> = {
15
+ "\\": "\\\\",
16
+ "\t": "\\t",
17
+ "\n": "\\n",
18
+ "\r": "\\r",
19
+ "\f": "\\f",
20
+ "=": "\\=",
21
+ ":": "\\:",
22
+ "#": "\\#",
23
+ "!": "\\!",
24
+ };
25
+
26
+ const PRINTABLE_MIN = 0x20;
27
+
28
+ const PRINTABLE_MAX = 0x7e;
29
+
30
+ const LATIN1_MAX = 0xff;
31
+
32
+ const HEX_DIGITS = 4;
33
+
34
+ const latin1Decoder = new TextDecoder("utf-8", {
35
+ fatal: true,
36
+ });
37
+
38
+ const repairLatin1 = (text: string) => {
39
+ const codes = Array.from(text, (character) => character.charCodeAt(0));
40
+
41
+ if (codes.every((code) => code <= PRINTABLE_MAX) || codes.some((code) => code > LATIN1_MAX)) {
42
+ return text;
43
+ }
44
+
45
+ try {
46
+ return latin1Decoder.decode(Uint8Array.from(codes));
47
+ } catch {
48
+ return text;
49
+ }
50
+ };
51
+
52
+ export const unescapeJavaProperty = (text: string) => {
53
+ const unescaped = text
54
+ .replace(UNICODE_ESCAPE, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)))
55
+ .replace(SIMPLE_ESCAPE, (_, character: string) => SIMPLE_ESCAPES[character] ?? character);
56
+
57
+ return repairLatin1(unescaped);
58
+ };
59
+
60
+ export const escapeJavaProperty = (text: string) => {
61
+ let escaped = "";
62
+
63
+ for (const character of text) {
64
+ const code = character.codePointAt(0) ?? 0;
65
+ const known = ESCAPED_CHARACTERS[character];
66
+
67
+ if (known !== undefined) {
68
+ escaped += known;
69
+ } else if (code < PRINTABLE_MIN || code > PRINTABLE_MAX) {
70
+ for (let index = 0; index < character.length; index += 1) {
71
+ escaped += `\\u${character.charCodeAt(index).toString(16).toUpperCase().padStart(HEX_DIGITS, "0")}`;
72
+ }
73
+ } else {
74
+ escaped += character;
75
+ }
76
+ }
77
+
78
+ return escaped.startsWith(" ") ? `\\${escaped}` : escaped;
79
+ };
80
+
81
+ export const parseJavaProperties = (contents: string) => {
82
+ const values: Record<string, string> = {};
83
+
84
+ for (const [key, value] of Object.entries(parseProperties(contents))) {
85
+ values[unescapeJavaProperty(key)] = unescapeJavaProperty(value);
86
+ }
87
+
88
+ return values;
89
+ };
90
+
91
+ export const mergeJavaProperties = (contents: string, values: Record<string, string>) => {
92
+ const escaped: Record<string, string> = {};
93
+
94
+ for (const [key, value] of Object.entries(values)) {
95
+ escaped[escapeJavaProperty(key)] = escapeJavaProperty(value);
96
+ }
97
+
98
+ return mergeProperties(contents, escaped);
99
+ };
@@ -66,6 +66,7 @@ export interface BridgeCodec {
66
66
 
67
67
  export interface BridgeCodecs {
68
68
  ini: BridgeCodec;
69
+ javaProperties: BridgeCodec;
69
70
  json: BridgeCodec;
70
71
  properties: BridgeCodec;
71
72
  sourceCfg: BridgeCodec;
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import type { BridgeRow } from "./bridgeValue";
2
3
  import { isLocalizedText } from "./bridgeValue";
3
4
 
4
5
  describe("isLocalizedText", () => {
@@ -27,3 +28,20 @@ describe("isLocalizedText", () => {
27
28
  ).toBe(false);
28
29
  });
29
30
  });
31
+
32
+ describe("BridgeRow", () => {
33
+ test('carries a localized cell beside its scalars so a driver never writes "English / عربي" into one string', () => {
34
+ const row: BridgeRow = {
35
+ id: "u-1",
36
+ kills: 12,
37
+ banned: false,
38
+ status: {
39
+ ar: "متصل",
40
+ en: "Online",
41
+ },
42
+ };
43
+
44
+ expect(isLocalizedText(row.status)).toBe(true);
45
+ expect(isLocalizedText(row.kills)).toBe(false);
46
+ });
47
+ });
@@ -2,15 +2,19 @@ export type BridgeValue = boolean | number | string | null;
2
2
 
3
3
  export type BridgeValues = Record<string, BridgeValue>;
4
4
 
5
- export type BridgeRow = BridgeValues & {
6
- id: string;
7
- };
8
-
9
5
  export interface LocalizedText {
10
6
  ar: string;
11
7
  en: string;
12
8
  }
13
9
 
10
+ export type BridgeCell = BridgeValue | LocalizedText;
11
+
12
+ export type BridgeCells = Record<string, BridgeCell>;
13
+
14
+ export type BridgeRow = BridgeCells & {
15
+ id: string;
16
+ };
17
+
14
18
  export type BridgeCopy = LocalizedText | string;
15
19
 
16
20
  export const isLocalizedText = (value: unknown): value is LocalizedText => {
@@ -1,5 +1,7 @@
1
1
  import type { BridgeContext, BridgeExecResult, BridgeFileEntry, BridgeReply } from "../context";
2
2
  import type {
3
+ BridgeCell,
4
+ BridgeCells,
3
5
  BridgeCopy,
4
6
  BridgeInstallHold,
5
7
  BridgeInstallOutcome,
@@ -11,6 +13,7 @@ import type {
11
13
  } from "../core";
12
14
  import { BridgeUserError } from "../core";
13
15
  import type {
16
+ BridgeAddOutcome,
14
17
  BridgeDetail,
15
18
  BridgeDetailBadge,
16
19
  BridgeDetailLink,
@@ -72,6 +75,7 @@ import type {
72
75
 
73
76
  export namespace Bridge {
74
77
  export type Actions = BridgeActionsModule;
78
+ export type AddOutcome = BridgeAddOutcome;
75
79
  export type Announce = BridgeAnnounceModule;
76
80
  export type Backup = BridgeBackupModule;
77
81
  export type Catalog = BridgeCatalogModule;
@@ -81,6 +85,8 @@ export namespace Bridge {
81
85
  export type CatalogPage = BridgeCatalogPage;
82
86
  export type CatalogProvider = BridgeCatalogProvider;
83
87
  export type CatalogQuery = BridgeCatalogQuery;
88
+ export type Cell = BridgeCell;
89
+ export type Cells = BridgeCells;
84
90
  export type Collection<Row extends BridgeRow = BridgeRow> = BridgeCollectionModule<Row>;
85
91
  export type Context = BridgeContext;
86
92
  export type Copy = BridgeCopy;
@@ -1,6 +1,6 @@
1
1
  import type { BridgeContext, BridgeReply } from "../context";
2
2
  import type { BridgeInstallOutcome, BridgeKind, BridgeOption, BridgeRow, BridgeStream, BridgeValues } from "../core";
3
- import type { BridgeDetail, BridgeDetailOutcome } from "../panel";
3
+ import type { BridgeAddOutcome, BridgeDetail, BridgeDetailOutcome } from "../panel";
4
4
  import type {
5
5
  BridgeCatalogEntry,
6
6
  BridgeCatalogPage,
@@ -131,7 +131,7 @@ export interface BridgeCollectionModule<Row extends BridgeRow = BridgeRow> {
131
131
  requiresRunning?: boolean;
132
132
  refreshSeconds?: number;
133
133
  list(context: BridgeContext): Promise<Row[]>;
134
- add?(context: BridgeContext, input: string): Promise<void>;
134
+ add?(context: BridgeContext, input: string): Promise<BridgeAddOutcome | undefined>;
135
135
  actions?: Record<string, BridgeRowAction<Row>>;
136
136
  }
137
137
 
@@ -0,0 +1,5 @@
1
+ import type { LocalizedText } from "../core";
2
+
3
+ export interface BridgeAddOutcome {
4
+ notice: LocalizedText;
5
+ }
@@ -1,4 +1,5 @@
1
1
  export * from "./bridgeAccess";
2
+ export * from "./bridgeCollection";
2
3
  export * from "./bridgeConfirm";
3
4
  export * from "./bridgeControl";
4
5
  export * from "./bridgeDetail";
@@ -286,6 +286,35 @@ describe("rconAccessSections", () => {
286
286
  expect(detail.empty).toBeDefined();
287
287
  });
288
288
 
289
+ test("the two sections carry different titles so the panel never repeats a heading", () => {
290
+ const [form, detail] = rconAccessSections();
291
+
292
+ expect(form?.title).toEqual({
293
+ ar: "الوصول عن بُعد",
294
+ en: "Remote access",
295
+ });
296
+ expect(detail?.title).toEqual({
297
+ ar: "بيانات الوصول عن بُعد",
298
+ en: "Remote access details",
299
+ });
300
+ expect(detail?.title).not.toEqual(form?.title);
301
+ });
302
+
303
+ test("the detail is visible only while remote access is on", () => {
304
+ const [, detail] = rconAccessSections();
305
+
306
+ if (detail?.layout !== BridgeLayout.Detail) {
307
+ throw new Error("the second section is the detail");
308
+ }
309
+
310
+ expect(detail.visibleWhen).toEqual({
311
+ variable: RCON_ACCESS_VARIABLE,
312
+ values: [
313
+ "true",
314
+ ],
315
+ });
316
+ });
317
+
289
318
  test("a dedicated port opens a port and a shared one only turns access on", () => {
290
319
  const [dedicated] = rconAccessSections({
291
320
  portKey: RCON_ACCESS_PORT,
@@ -49,6 +49,11 @@ const ACCESS_TITLE: LocalizedText = {
49
49
  en: "Remote access",
50
50
  };
51
51
 
52
+ const ACCESS_DETAIL_TITLE: LocalizedText = {
53
+ ar: "بيانات الوصول عن بُعد",
54
+ en: "Remote access details",
55
+ };
56
+
52
57
  const ACCESS_DESCRIPTION: LocalizedText = {
53
58
  ar: "الوصول عن بُعد يخلي أداة خارجية تتحكم بسيرفرك بكلمة المرور هذي. الكلمة تمشي على النت بدون تشفير، فاقفل الوصول لما ما فيه أداة تستخدمه، ولا تعطي الكلمة أحد. لو تسربت غيّرها على طول.",
54
59
  en: "Remote access lets an external tool control your server with this password. The password crosses the internet in the clear, so turn remote access off when no tool is using it, and never share the password. Rotate it the moment it leaks.",
@@ -235,7 +240,7 @@ export const rconAccessSections = (options: RconAccessSectionsOptions = {}): Bri
235
240
  {
236
241
  layout: BridgeLayout.Detail,
237
242
  id: DETAIL_SECTION_ID,
238
- title: ACCESS_TITLE,
243
+ title: ACCESS_DETAIL_TITLE,
239
244
  module: RCON_ACCESS_MODULE,
240
245
  actions: [
241
246
  {
@@ -255,6 +260,12 @@ export const rconAccessSections = (options: RconAccessSectionsOptions = {}): Bri
255
260
  ar: "شغّل سيرفرك مرة عشان تتولد كلمة المرور.",
256
261
  en: "Start your server once to generate the password.",
257
262
  },
263
+ visibleWhen: {
264
+ variable: RCON_ACCESS_VARIABLE,
265
+ values: [
266
+ "true",
267
+ ],
268
+ },
258
269
  },
259
270
  ];
260
271
  };
@@ -1,11 +1,13 @@
1
1
  import {
2
2
  mergeIni,
3
+ mergeJavaProperties,
3
4
  mergeJson,
4
5
  mergeProperties,
5
6
  mergeSourceCfg,
6
7
  mergeUeIni,
7
8
  mergeXmlProperties,
8
9
  parseIni,
10
+ parseJavaProperties,
9
11
  parseJson,
10
12
  parseProperties,
11
13
  parseSourceCfg,
@@ -61,6 +63,15 @@ export const createBridgeCodecs = (files: BridgeFiles): BridgeCodecs => {
61
63
  },
62
64
  };
63
65
 
66
+ const javaProperties: BridgeCodec = {
67
+ async read(path) {
68
+ return widen(parseJavaProperties(await readOr(files, path)));
69
+ },
70
+ async merge(path, values) {
71
+ await files.write(path, mergeJavaProperties(await readOr(files, path), toStrings(values)));
72
+ },
73
+ };
74
+
64
75
  const json: BridgeCodec = {
65
76
  async read(path) {
66
77
  return parseJson(await readOr(files, path));
@@ -112,6 +123,7 @@ export const createBridgeCodecs = (files: BridgeFiles): BridgeCodecs => {
112
123
 
113
124
  return {
114
125
  ini,
126
+ javaProperties,
115
127
  json,
116
128
  properties,
117
129
  sourceCfg,
@@ -324,9 +324,7 @@ export const createRegistry = (
324
324
  }
325
325
 
326
326
  if (action === BridgeAction.Add && module.add) {
327
- await module.add(context, asString(args, "input"));
328
-
329
- return null;
327
+ return (await module.add(context, asString(args, "input"))) ?? null;
330
328
  }
331
329
 
332
330
  const rowAction = module.actions?.[action];
@@ -15,7 +15,7 @@ import {
15
15
  BridgeSetupStepKind,
16
16
  BridgeUploadMode,
17
17
  } from "@serverkgg/bridge/protocol";
18
- import { bridgeManifestSchema } from "./panel";
18
+ import { bridgeCellsSchema, bridgeManifestSchema } from "./panel";
19
19
 
20
20
  const text = (value: string) => ({
21
21
  ar: value,
@@ -683,3 +683,37 @@ describe("the placement mirror", () => {
683
683
  expect(bridgeManifestSchema.parse(source)).toEqual(source);
684
684
  });
685
685
  });
686
+
687
+ describe("the cell schema", () => {
688
+ test("accepts a scalar and a localized cell", () => {
689
+ expect(
690
+ bridgeCellsSchema.parse({
691
+ id: "u-1",
692
+ kills: 12,
693
+ banned: false,
694
+ status: {
695
+ ar: "متصل",
696
+ en: "Online",
697
+ },
698
+ }),
699
+ ).toEqual({
700
+ id: "u-1",
701
+ kills: 12,
702
+ banned: false,
703
+ status: {
704
+ ar: "متصل",
705
+ en: "Online",
706
+ },
707
+ });
708
+ });
709
+
710
+ test("rejects a cell that carries only one language", () => {
711
+ expect(
712
+ bridgeCellsSchema.safeParse({
713
+ status: {
714
+ ar: "متصل",
715
+ },
716
+ }).success,
717
+ ).toBe(false);
718
+ });
719
+ });
package/src/wire/panel.ts CHANGED
@@ -68,6 +68,17 @@ export const panelTextSchema = z.object({
68
68
  en: z.string().min(1).max(PANEL_TEXT_LENGTH_LIMIT),
69
69
  });
70
70
 
71
+ export const bridgeCellSchema = z.union([
72
+ bridgeValueSchema,
73
+ panelTextSchema,
74
+ ]);
75
+
76
+ export type BridgeCellInput = z.infer<typeof bridgeCellSchema>;
77
+
78
+ export const bridgeCellsSchema = z.record(zKey, bridgeCellSchema);
79
+
80
+ export type BridgeCellsInput = z.infer<typeof bridgeCellsSchema>;
81
+
71
82
  export const bridgeOptionSchema = z.object({
72
83
  value: z.string().min(1).max(128),
73
84
  label: panelTextSchema,