@serverkgg/bridge 0.0.18 → 0.0.20

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.18",
3
+ "version": "0.0.20",
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",
@@ -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,
@@ -83,6 +85,8 @@ export namespace Bridge {
83
85
  export type CatalogPage = BridgeCatalogPage;
84
86
  export type CatalogProvider = BridgeCatalogProvider;
85
87
  export type CatalogQuery = BridgeCatalogQuery;
88
+ export type Cell = BridgeCell;
89
+ export type Cells = BridgeCells;
86
90
  export type Collection<Row extends BridgeRow = BridgeRow> = BridgeCollectionModule<Row>;
87
91
  export type Context = BridgeContext;
88
92
  export type Copy = BridgeCopy;
@@ -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
  };
@@ -981,19 +981,73 @@ describe("list", () => {
981
981
  expect((await files.list("mods/*/*.jar")).map((entry) => entry.path)).toEqual([
982
982
  "mods/custom/plugin.jar",
983
983
  ]);
984
- await expect(files.list("**/*.jar")).rejects.toThrow("directory depth limit");
984
+ expect((await files.list("**/*.jar")).map((entry) => entry.path).sort()).toEqual([
985
+ "mods/custom/plugin.jar",
986
+ "server.jar",
987
+ ]);
988
+ });
989
+ test("prunes subtrees past the depth limit instead of failing the whole listing", async () => {
990
+ const nested = Array.from(
991
+ {
992
+ length: 14,
993
+ },
994
+ () => "nested",
995
+ ).join("/");
996
+ await files.write("resource_packs/pack/manifest.json", "{}");
997
+ await files.write("resource_packs/pack/pack_icon.png", "icon");
998
+ await files.write(`resource_packs/pack/models/${nested}/obfuscated.json`, "{}");
999
+ expect((await files.list("**/*")).map((entry) => entry.path).sort()).toEqual([
1000
+ "resource_packs/pack/manifest.json",
1001
+ "resource_packs/pack/pack_icon.png",
1002
+ ]);
1003
+ expect((await files.list("resource_packs/**/*.json")).map((entry) => entry.path)).toEqual([
1004
+ "resource_packs/pack/manifest.json",
1005
+ ]);
1006
+ expect(
1007
+ (
1008
+ await files.list("**/*", {
1009
+ directory: "resource_packs/pack",
1010
+ })
1011
+ )
1012
+ .map((entry) => entry.path)
1013
+ .sort(),
1014
+ ).toEqual([
1015
+ "resource_packs/pack/manifest.json",
1016
+ "resource_packs/pack/pack_icon.png",
1017
+ ]);
985
1018
  });
986
- test("refuses partial deep listings and unsafe listing roots", async () => {
987
- await files.write(
988
- `${Array.from(
1019
+ test("warns about what a pruned listing left out", async () => {
1020
+ const lines: string[] = [];
1021
+ const noisy = createBridgeFiles({
1022
+ root,
1023
+ uid: 0,
1024
+ gid: 0,
1025
+ egress,
1026
+ onLog: Object.assign(() => undefined, {
1027
+ warn: (message: string) => {
1028
+ lines.push(message);
1029
+ },
1030
+ error: () => undefined,
1031
+ }),
1032
+ });
1033
+ await noisy.write("manifest.json", "{}");
1034
+ await noisy.write(
1035
+ `models/${Array.from(
989
1036
  {
990
1037
  length: 14,
991
1038
  },
992
1039
  () => "nested",
993
- ).join("/")}/file.txt`,
994
- "data",
1040
+ ).join("/")}/obfuscated.json`,
1041
+ "{}",
995
1042
  );
996
- await expect(files.list("**/*")).rejects.toThrow("directory depth limit");
1043
+ expect((await noisy.list("**/*.json")).map((entry) => entry.path)).toEqual([
1044
+ "manifest.json",
1045
+ ]);
1046
+ expect(lines).toEqual([
1047
+ "file listing skipped directories deeper than its depth limit",
1048
+ ]);
1049
+ });
1050
+ test("refuses unsafe listing roots", async () => {
997
1051
  await expect(
998
1052
  files.list("**/*", {
999
1053
  directory: "../outside",
@@ -209,10 +209,13 @@ const walk = async (
209
209
  directoryPatterns: RegExp[],
210
210
  scan: {
211
211
  visited: number;
212
+ pruned: number;
212
213
  },
213
214
  ) => {
214
215
  if (depth > WALK_DEPTH) {
215
- throw new BridgePathError("invalid", "file listing exceeded its directory depth limit");
216
+ scan.pruned += 1;
217
+
218
+ return;
216
219
  }
217
220
 
218
221
  let entries: Dirent[];
@@ -392,9 +395,18 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
392
395
  if (!resolved.isDirectory) {
393
396
  throw new BridgePathError("invalid", `path "${prefix}" is not a directory to list`);
394
397
  }
395
- await walk(resolved.target, prefix, 0, entries, pattern, directoryPatterns, {
398
+ const scan = {
396
399
  visited: 0,
397
- });
400
+ pruned: 0,
401
+ };
402
+ await walk(resolved.target, prefix, 0, entries, pattern, directoryPatterns, scan);
403
+ if (scan.pruned > 0) {
404
+ options.onLog.warn("file listing skipped directories deeper than its depth limit", {
405
+ glob,
406
+ depth: WALK_DEPTH,
407
+ skipped: scan.pruned,
408
+ });
409
+ }
398
410
  return entries;
399
411
  },
400
412
 
@@ -4,7 +4,7 @@ import { BridgeRuntime } from "../core";
4
4
  import { BridgeCrashPhase, type BridgeCrashRecord } from "../protocol";
5
5
  import type { BridgeLeveledLogger } from "./bridgeLog";
6
6
  import { readProcessGroup } from "./bridgeSignal";
7
- import { createSupervisor } from "./bridgeSupervisor";
7
+ import { type BridgeSupervisor, createSupervisor } from "./bridgeSupervisor";
8
8
 
9
9
  const CHILD_PATTERN = /child=(?<pid>\d+)/;
10
10
 
@@ -169,6 +169,55 @@ const timedSupervisor = (command: () => string, ready: RegExp, stableAfterMs: nu
169
169
  };
170
170
  };
171
171
 
172
+ const racedSupervisor = (delayMs: number, resolve: () => string[]) => {
173
+ const pids: number[] = [];
174
+
175
+ let launches = 0;
176
+ let overlapped = false;
177
+
178
+ const supervisor: BridgeSupervisor = createSupervisor({
179
+ cwd: tmpdir(),
180
+ uid: process.getuid?.() ?? 0,
181
+ gid: process.getgid?.() ?? 0,
182
+ stopTimeoutSeconds: 1,
183
+ onLog: silent,
184
+ async resolveCommand() {
185
+ launches += 1;
186
+
187
+ if (supervisor.running) {
188
+ overlapped = true;
189
+ }
190
+
191
+ await Bun.sleep(delayMs);
192
+
193
+ return resolve();
194
+ },
195
+ });
196
+
197
+ supervisor.subscribe((entry) => {
198
+ const pid = Number(entry.line.match(CHILD_PATTERN)?.groups?.pid);
199
+
200
+ if (Number.isSafeInteger(pid)) {
201
+ pids.push(pid);
202
+ }
203
+ });
204
+
205
+ return {
206
+ supervisor,
207
+ pids,
208
+ launches: () => launches,
209
+ overlapped: () => overlapped,
210
+ };
211
+ };
212
+
213
+ const wrapperArgv = () => {
214
+ return [
215
+ "bash",
216
+ "-c",
217
+ WRAPPER,
218
+ ];
219
+ };
220
+
172
221
  describe("createSupervisor", () => {
173
222
  test("runs the game outside the bridge's own process group", async () => {
174
223
  const { supervisor, pid } = await startWrapper(WRAPPER, 1);
@@ -397,4 +446,77 @@ describe("createSupervisor", () => {
397
446
 
398
447
  await supervisor.stop();
399
448
  }, 40_000);
449
+
450
+ test("spawns one game when two starts race inside the launch window", async () => {
451
+ const { supervisor, pids, launches, overlapped } = racedSupervisor(200, wrapperArgv);
452
+
453
+ await Promise.all([
454
+ supervisor.start(),
455
+ supervisor.start(),
456
+ ]);
457
+
458
+ expect(launches(), "the second start entered the launch path beside the first").toBe(1);
459
+ expect(await waitFor(() => pids.length > 0, 10_000)).toBe(true);
460
+
461
+ await Bun.sleep(500);
462
+
463
+ expect(pids, "the race spawned a second game").toHaveLength(1);
464
+ expect(overlapped()).toBe(false);
465
+
466
+ await supervisor.stop();
467
+
468
+ expect(await waitGone(pids.at(0) ?? 0, 5000)).toBe(true);
469
+ }, 30_000);
470
+
471
+ test("never spawns beside the game a stop is taking down", async () => {
472
+ const { supervisor, pids, launches, overlapped } = racedSupervisor(0, wrapperArgv);
473
+
474
+ await supervisor.start();
475
+
476
+ expect(await waitFor(() => pids.length === 1, 10_000)).toBe(true);
477
+
478
+ const first = pids.at(0) ?? 0;
479
+
480
+ const stopping = supervisor.stop();
481
+ const starting = supervisor.start();
482
+
483
+ await stopping;
484
+ await starting;
485
+
486
+ expect(await waitFor(() => pids.length === 2, 10_000)).toBe(true);
487
+ expect(launches()).toBe(2);
488
+ expect(overlapped(), "the queued start spawned while the stopped game was still alive").toBe(false);
489
+ expect(await waitGone(first, 5000)).toBe(true);
490
+ expect(supervisor.running).toBe(true);
491
+
492
+ await supervisor.stop();
493
+
494
+ expect(await waitGone(pids.at(1) ?? 0, 5000)).toBe(true);
495
+ }, 30_000);
496
+
497
+ test("starts again after a launch that threw before the game was spawned", async () => {
498
+ let resolvable = false;
499
+
500
+ const { supervisor, pids } = racedSupervisor(0, () => {
501
+ if (!resolvable) {
502
+ throw new Error("the start command could not be resolved");
503
+ }
504
+
505
+ return wrapperArgv();
506
+ });
507
+
508
+ await expect(supervisor.start()).rejects.toThrow("the start command could not be resolved");
509
+ expect(supervisor.running).toBe(false);
510
+
511
+ resolvable = true;
512
+
513
+ await supervisor.start();
514
+
515
+ expect(supervisor.running, "the failed launch wedged the supervisor").toBe(true);
516
+ expect(await waitFor(() => pids.length === 1, 10_000)).toBe(true);
517
+
518
+ await supervisor.stop();
519
+
520
+ expect(await waitGone(pids.at(0) ?? 0, 5000)).toBe(true);
521
+ }, 30_000);
400
522
  });
@@ -135,6 +135,21 @@ const drainPumps = async (pumps: readonly Promise<unknown>[]) => {
135
135
  ]);
136
136
  };
137
137
 
138
+ const chain = () => {
139
+ let queue: Promise<unknown> = Promise.resolve();
140
+
141
+ return <Result>(run: () => Promise<Result>): Promise<Result> => {
142
+ const next = queue.then(run, run);
143
+
144
+ queue = next.then(
145
+ () => undefined,
146
+ () => undefined,
147
+ );
148
+
149
+ return next;
150
+ };
151
+ };
152
+
138
153
  export const createSupervisor = (options: SupervisorOptions) => {
139
154
  const tail: SupervisorLine[] = [];
140
155
  const watchers = new Set<LineWatcher>();
@@ -151,7 +166,9 @@ export const createSupervisor = (options: SupervisorOptions) => {
151
166
  let launchedAt = performance.now();
152
167
  let stable = false;
153
168
  let stableTimer: ReturnType<typeof setTimeout> | null = null;
154
- let queue: Promise<unknown> = Promise.resolve();
169
+
170
+ const serialize = chain();
171
+ const transition = chain();
155
172
 
156
173
  const markStable = () => {
157
174
  if (stable) {
@@ -299,7 +316,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
299
316
 
300
317
  reported = true;
301
318
 
302
- void handleExit(code, pumps).catch((error: unknown) => {
319
+ void handleExit(spawned, code, pumps).catch((error: unknown) => {
303
320
  options.onLog.error("the game exit was not reported", {
304
321
  error: errorText(error),
305
322
  });
@@ -418,24 +435,24 @@ export const createSupervisor = (options: SupervisorOptions) => {
418
435
  const restart = async () => {
419
436
  await Bun.sleep(RESTART_DELAY_MS);
420
437
 
421
- if (!shouldRun || breaker || child !== null) {
422
- return;
423
- }
438
+ await transition(async () => {
439
+ if (!shouldRun || breaker || child !== null) {
440
+ return;
441
+ }
424
442
 
425
- try {
426
- await launch();
427
- } catch (error) {
428
- runtime = BridgeRuntime.Crashed;
443
+ try {
444
+ await launch();
445
+ } catch (error) {
446
+ runtime = BridgeRuntime.Crashed;
429
447
 
430
- options.onLog.error("game failed to restart", {
431
- error: errorText(error),
432
- });
433
- }
448
+ options.onLog.error("game failed to restart", {
449
+ error: errorText(error),
450
+ });
451
+ }
452
+ });
434
453
  };
435
454
 
436
- async function handleExit(code: number, pumps: readonly Promise<unknown>[]) {
437
- const finished = child;
438
-
455
+ const reap = (finished: SpawnedGame, code: number) => {
439
456
  child = null;
440
457
  exitCode = code;
441
458
 
@@ -445,11 +462,17 @@ export const createSupervisor = (options: SupervisorOptions) => {
445
462
  stableTimer = null;
446
463
  }
447
464
 
448
- if (finished) {
449
- sweep(finished.pid, childGroup);
450
- }
465
+ sweep(finished.pid, childGroup);
451
466
 
452
467
  childGroup = null;
468
+ };
469
+
470
+ async function handleExit(finished: SpawnedGame, code: number, pumps: readonly Promise<unknown>[]) {
471
+ if (finished !== child) {
472
+ return;
473
+ }
474
+
475
+ reap(finished, code);
453
476
 
454
477
  if (!shouldRun) {
455
478
  outOfMemory = false;
@@ -535,17 +558,6 @@ export const createSupervisor = (options: SupervisorOptions) => {
535
558
  void restart();
536
559
  }
537
560
 
538
- const serialize = <Result>(run: () => Promise<Result>): Promise<Result> => {
539
- const next = queue.then(run, run);
540
-
541
- queue = next.then(
542
- () => undefined,
543
- () => undefined,
544
- );
545
-
546
- return next;
547
- };
548
-
549
561
  const writeLine = async (input: string) => {
550
562
  if (!child) {
551
563
  throw new Error("the game is not running");
@@ -578,73 +590,89 @@ export const createSupervisor = (options: SupervisorOptions) => {
578
590
  },
579
591
 
580
592
  async start() {
581
- shouldRun = true;
582
- breaker = false;
583
- outOfMemory = false;
584
- restarts = 0;
585
- restartStamps.length = 0;
586
-
587
- if (child) {
588
- return;
589
- }
593
+ await transition(async () => {
594
+ shouldRun = true;
595
+ breaker = false;
596
+ outOfMemory = false;
597
+ restarts = 0;
598
+ restartStamps.length = 0;
599
+
600
+ if (child) {
601
+ return;
602
+ }
590
603
 
591
- await launch();
604
+ await launch();
605
+ });
592
606
  },
593
607
 
594
608
  async stop() {
595
609
  shouldRun = false;
596
610
 
597
- if (!child) {
598
- runtime = BridgeRuntime.Stopped;
611
+ await transition(async () => {
612
+ shouldRun = false;
599
613
 
600
- options.onStopped?.();
614
+ if (!child) {
615
+ runtime = BridgeRuntime.Stopped;
601
616
 
602
- return;
603
- }
617
+ options.onStopped?.();
618
+
619
+ return;
620
+ }
604
621
 
605
- const active = child;
606
- const group = childGroup;
622
+ const active = child;
623
+ const group = childGroup;
607
624
 
608
- runtime = BridgeRuntime.Stopping;
625
+ runtime = BridgeRuntime.Stopping;
609
626
 
610
- if (options.gracefulStop) {
611
- const graceful = options.gracefulStop().catch((error: unknown) => {
612
- options.onLog.warn("graceful stop failed", {
613
- error: errorText(error),
627
+ if (options.gracefulStop) {
628
+ const graceful = options.gracefulStop().catch((error: unknown) => {
629
+ options.onLog.warn("graceful stop failed", {
630
+ error: errorText(error),
631
+ });
614
632
  });
615
- });
616
633
 
617
- await Promise.race([
618
- graceful,
634
+ await Promise.race([
635
+ graceful,
636
+ active.exited,
637
+ ]);
638
+ }
639
+
640
+ const settled = await Promise.race([
619
641
  active.exited,
642
+ Bun.sleep(options.stopTimeoutSeconds * 1000).then(() => null),
620
643
  ]);
621
- }
622
644
 
623
- const settled = await Promise.race([
624
- active.exited,
625
- Bun.sleep(options.stopTimeoutSeconds * 1000).then(() => null),
626
- ]);
645
+ if (settled === null) {
646
+ terminate(active, group, "SIGTERM");
627
647
 
628
- if (settled === null) {
629
- terminate(active, group, "SIGTERM");
648
+ const afterTerm = await Promise.race([
649
+ active.exited,
650
+ Bun.sleep(KILL_GRACE_MS).then(() => null),
651
+ ]);
630
652
 
631
- const afterTerm = await Promise.race([
632
- active.exited,
633
- Bun.sleep(KILL_GRACE_MS).then(() => null),
634
- ]);
653
+ if (afterTerm === null) {
654
+ terminate(active, group, "SIGKILL");
635
655
 
636
- if (afterTerm === null) {
637
- terminate(active, group, "SIGKILL");
638
-
639
- await active.exited;
656
+ await active.exited;
657
+ }
640
658
  }
641
- }
642
659
 
643
- sweep(active.pid, group);
660
+ if (child === active) {
661
+ const code = exitCodeOf(active.exitCode, active.signalCode);
644
662
 
645
- runtime = BridgeRuntime.Stopped;
663
+ reap(active, code);
664
+
665
+ outOfMemory = false;
666
+
667
+ options.onLog("game stopped", {
668
+ code,
669
+ });
670
+ }
646
671
 
647
- options.onStopped?.();
672
+ runtime = BridgeRuntime.Stopped;
673
+
674
+ options.onStopped?.();
675
+ });
648
676
  },
649
677
 
650
678
  async command(input: string, commandOptions: BridgeCommandOptions = {}): Promise<BridgeReply> {
@@ -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,