@serverkgg/bridge 0.0.8 → 0.0.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.
Files changed (72) hide show
  1. package/package.json +3 -3
  2. package/src/cli/validate.ts +2 -0
  3. package/src/context/bridgeContext.ts +17 -0
  4. package/src/context/index.ts +1 -0
  5. package/src/core/bridgeDownloadError.ts +10 -0
  6. package/src/core/bridgeHold.ts +12 -0
  7. package/src/core/bridgeKind.ts +3 -0
  8. package/src/core/bridgeProbeError.ts +16 -0
  9. package/src/core/bridgeSetup.ts +20 -0
  10. package/src/core/bridgeUserError.test.ts +23 -0
  11. package/src/core/bridgeUserError.ts +12 -0
  12. package/src/core/bridgeValue.test.ts +29 -0
  13. package/src/core/bridgeValue.ts +12 -0
  14. package/src/core/index.ts +5 -0
  15. package/src/guides/blocks.ts +8 -0
  16. package/src/guides/markdown.test.ts +18 -0
  17. package/src/guides/markdown.ts +7 -1
  18. package/src/manifest/index.ts +1 -0
  19. package/src/manifest/manifest.test.ts +132 -0
  20. package/src/manifest/manifest.ts +21 -0
  21. package/src/manifest/presence.ts +56 -0
  22. package/src/manifest/primitives.ts +6 -0
  23. package/src/modules/bridge.ts +65 -2
  24. package/src/modules/bridgeDriver.ts +4 -0
  25. package/src/modules/bridgeModule.ts +32 -3
  26. package/src/panel/bridgeDetail.ts +49 -0
  27. package/src/panel/bridgeLayout.ts +1 -0
  28. package/src/panel/bridgePanel.ts +30 -0
  29. package/src/panel/bridgeUploadMode.ts +4 -0
  30. package/src/panel/index.ts +2 -0
  31. package/src/probes/a2s.test.ts +71 -0
  32. package/src/probes/a2s.ts +38 -11
  33. package/src/protocol/bridgeEgress.ts +8 -1
  34. package/src/protocol/bridgeEvents.ts +4 -0
  35. package/src/protocol/bridgeFrame.ts +33 -1
  36. package/src/protocol/bridgeLimits.ts +12 -0
  37. package/src/protocol/bridgeManifest.ts +2 -0
  38. package/src/protocol/bridgeSetup.ts +97 -0
  39. package/src/protocol/index.ts +1 -0
  40. package/src/runtime/bridgeActions.ts +7 -0
  41. package/src/runtime/bridgeConfigReader.ts +12 -8
  42. package/src/runtime/bridgeContext.ts +28 -2
  43. package/src/runtime/bridgeDriver.test.ts +748 -3
  44. package/src/runtime/bridgeDriver.ts +198 -4
  45. package/src/runtime/bridgeEgress.ts +3 -0
  46. package/src/runtime/bridgeEvents.test.ts +96 -0
  47. package/src/runtime/bridgeEvents.ts +11 -6
  48. package/src/runtime/bridgeFiles.test.ts +625 -15
  49. package/src/runtime/bridgeFiles.ts +152 -45
  50. package/src/runtime/bridgeInstall.test.ts +118 -0
  51. package/src/runtime/bridgeInstall.ts +55 -0
  52. package/src/runtime/bridgeNet.ts +32 -3
  53. package/src/runtime/bridgePending.test.ts +148 -0
  54. package/src/runtime/bridgePending.ts +43 -0
  55. package/src/runtime/bridgeRegistry.test.ts +172 -1
  56. package/src/runtime/bridgeRegistry.ts +40 -5
  57. package/src/runtime/bridgeSecrets.test.ts +121 -0
  58. package/src/runtime/bridgeSecrets.ts +43 -0
  59. package/src/runtime/bridgeServer.test.ts +598 -0
  60. package/src/runtime/bridgeServer.ts +42 -3
  61. package/src/runtime/bridgeSetup.test.ts +189 -0
  62. package/src/runtime/bridgeSetup.ts +63 -0
  63. package/src/runtime/bridgeSignal.test.ts +114 -0
  64. package/src/runtime/bridgeSignal.ts +31 -0
  65. package/src/runtime/bridgeSupervisor.test.ts +111 -0
  66. package/src/runtime/bridgeSupervisor.ts +55 -5
  67. package/src/runtime/bridgeZip.ts +179 -58
  68. package/src/runtime/serve.ts +115 -4
  69. package/src/utils/index.ts +1 -0
  70. package/src/utils/stripAnsi.ts +12 -0
  71. package/src/wire/panel.test.ts +259 -0
  72. package/src/wire/panel.ts +72 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@serverkgg/bridge",
3
- "version": "0.0.8",
4
- "description": "Serverk game bridge — driver runtime, wire protocol, manifest schema, and maintainer CLI",
3
+ "version": "0.0.10",
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",
7
7
  "repository": {
@@ -29,6 +29,6 @@
29
29
  "test": "bun test --parallel"
30
30
  },
31
31
  "dependencies": {
32
- "zod": "4.4.3"
32
+ "zod": "4.5.4"
33
33
  }
34
34
  }
@@ -229,6 +229,8 @@ const validateGamePackage = async (source: PackageSource, manifest: Manifest, di
229
229
  console.log(
230
230
  ` driver: ${Object.keys(driver.modules ?? {}).length} panel module(s), ${driver.panel?.tabs.length ?? 0} tab(s)`,
231
231
  );
232
+ console.log(` pending: ${driver.pending ? `declared (${driver.pending.extensions.length} extension(s))` : "none"}`);
233
+ console.log(` setup: ${driver.setup ? `${driver.setup.steps.length} step(s)` : "none"}`);
232
234
  reportEvents(driver);
233
235
  reportActionTargets(driver);
234
236
  };
@@ -1,4 +1,5 @@
1
1
  import type { BridgeStream, BridgeValues } from "../core";
2
+ import type { BridgeHttpMethod, BridgeSetupRuntime } from "../protocol";
2
3
 
3
4
  export interface BridgeServer {
4
5
  code: string;
@@ -15,16 +16,22 @@ export interface BridgeFileEntry {
15
16
  modifiedAt: string;
16
17
  }
17
18
 
19
+ export type BridgeDigestAlgorithm = "sha1" | "sha256" | "sha512";
20
+
18
21
  export interface BridgeDownloadOptions {
19
22
  digest?: string;
20
23
  sizeBytes?: number;
21
24
  cache?: boolean;
25
+ quiet?: boolean;
26
+ timeoutMs?: number;
22
27
  }
23
28
 
24
29
  export interface BridgeExtractOptions {
25
30
  extensions?: string[];
26
31
  tree?: boolean;
27
32
  select?: string;
33
+ selects?: string[];
34
+ exclude?: string[];
28
35
  }
29
36
 
30
37
  export interface BridgeFiles {
@@ -36,6 +43,7 @@ export interface BridgeFiles {
36
43
  ensure(...paths: string[]): Promise<void>;
37
44
  exists(path: string): Promise<boolean>;
38
45
  size(path: string): Promise<number>;
46
+ digest(path: string, algorithm: BridgeDigestAlgorithm): Promise<string>;
39
47
  download(path: string, url: string, options?: BridgeDownloadOptions): Promise<void>;
40
48
  extract(path: string, destination: string, options?: BridgeExtractOptions): Promise<string[]>;
41
49
  }
@@ -57,6 +65,8 @@ export interface BridgeCodecs {
57
65
  }
58
66
 
59
67
  export interface BridgeRequestOptions {
68
+ httpMethod?: BridgeHttpMethod;
69
+ body?: string;
60
70
  headers?: Record<string, string>;
61
71
  timeoutMs?: number;
62
72
  cache?: boolean;
@@ -111,6 +121,12 @@ export interface BridgeExecResult {
111
121
  export interface BridgeLogs {
112
122
  tail(lines: number): Promise<string[]>;
113
123
  watch(pattern: RegExp, timeoutMs: number): Promise<RegExpMatchArray | null>;
124
+ follow(pattern: RegExp, onMatch: (match: RegExpMatchArray) => void): () => void;
125
+ }
126
+
127
+ export interface BridgeSetupReporter {
128
+ report(runtime: BridgeSetupRuntime): void;
129
+ clear(): void;
114
130
  }
115
131
 
116
132
  export interface BridgeLog {
@@ -131,6 +147,7 @@ export interface BridgeContext {
131
147
  command(input: string, options?: BridgeCommandOptions): Promise<BridgeReply>;
132
148
  exec(command: string[], options?: BridgeExecOptions): Promise<BridgeExecResult>;
133
149
  logs: BridgeLogs;
150
+ setup: BridgeSetupReporter;
134
151
  emit(event: string, payload?: BridgeValues): void;
135
152
  log: BridgeLog;
136
153
  }
@@ -1 +1,2 @@
1
+ export { BridgeHttpMethod } from "../protocol";
1
2
  export * from "./bridgeContext";
@@ -0,0 +1,10 @@
1
+ export class BridgeDownloadError extends Error {
2
+ readonly status: number | null;
3
+
4
+ constructor(status: number | null, message: string) {
5
+ super(message);
6
+
7
+ this.name = "BridgeDownloadError";
8
+ this.status = status;
9
+ }
10
+ }
@@ -0,0 +1,12 @@
1
+ export enum BridgeHoldReason {
2
+ PendingFiles = "pending-files",
3
+ }
4
+
5
+ export interface BridgeInstallHold {
6
+ reason: BridgeHoldReason;
7
+ count: number;
8
+ }
9
+
10
+ export interface BridgeInstallOutcome {
11
+ hold: BridgeInstallHold | null;
12
+ }
@@ -4,12 +4,15 @@ export enum BridgeKind {
4
4
  Backup = "backup",
5
5
  Catalog = "catalog",
6
6
  Collection = "collection",
7
+ Detail = "detail",
7
8
  Terminal = "terminal",
8
9
  Events = "events",
9
10
  Install = "install",
10
11
  Lifecycle = "lifecycle",
11
12
  Options = "options",
13
+ Pending = "pending",
12
14
  Preview = "preview",
13
15
  Query = "query",
14
16
  Settings = "settings",
17
+ Setup = "setup",
15
18
  }
@@ -0,0 +1,16 @@
1
+ export enum BridgeProbeCode {
2
+ Unreachable = "unreachable",
3
+ TimedOut = "timed-out",
4
+ Malformed = "malformed",
5
+ }
6
+
7
+ export class BridgeProbeError extends Error {
8
+ readonly code: BridgeProbeCode;
9
+
10
+ constructor(code: BridgeProbeCode, message: string) {
11
+ super(message);
12
+
13
+ this.name = "BridgeProbeError";
14
+ this.code = code;
15
+ }
16
+ }
@@ -0,0 +1,20 @@
1
+ export enum BridgeSetupStepKind {
2
+ Driver = "driver",
3
+ Form = "form",
4
+ Open = "open",
5
+ }
6
+
7
+ export enum BridgeSetupStepState {
8
+ Pending = "pending",
9
+ Active = "active",
10
+ Done = "done",
11
+ Failed = "failed",
12
+ }
13
+
14
+ export enum BridgeSetupPromptKind {
15
+ Wait = "wait",
16
+ Progress = "progress",
17
+ Link = "link",
18
+ Action = "action",
19
+ Failed = "failed",
20
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Bridge } from "../modules";
3
+ import { BridgeUserError } from "./bridgeUserError";
4
+
5
+ describe("BridgeUserError", () => {
6
+ const text = {
7
+ ar: "هذا مو الملف اللي ننتظره",
8
+ en: "this is not a file we are waiting for",
9
+ };
10
+
11
+ test("reads as english in a log line and keeps both languages for the player", () => {
12
+ const error = new BridgeUserError(text);
13
+
14
+ expect(error).toBeInstanceOf(Error);
15
+ expect(error.name).toBe("BridgeUserError");
16
+ expect(error.message).toBe(text.en);
17
+ expect(error.text).toEqual(text);
18
+ });
19
+
20
+ test("is the class a driver reaches through the published namespace", () => {
21
+ expect(new Bridge.UserError(text)).toBeInstanceOf(BridgeUserError);
22
+ });
23
+ });
@@ -0,0 +1,12 @@
1
+ import type { LocalizedText } from "./bridgeValue";
2
+
3
+ export class BridgeUserError extends Error {
4
+ readonly text: LocalizedText;
5
+
6
+ constructor(text: LocalizedText) {
7
+ super(text.en);
8
+
9
+ this.name = "BridgeUserError";
10
+ this.text = text;
11
+ }
12
+ }
@@ -0,0 +1,29 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { isLocalizedText } from "./bridgeValue";
3
+
4
+ describe("isLocalizedText", () => {
5
+ test("recognises the shape a driver authors copy in", () => {
6
+ expect(
7
+ isLocalizedText({
8
+ ar: "التركيب",
9
+ en: "Setup",
10
+ }),
11
+ ).toBe(true);
12
+ });
13
+
14
+ test("leaves every other bridge value alone", () => {
15
+ expect(isLocalizedText("Hytale")).toBe(false);
16
+ expect(isLocalizedText(12)).toBe(false);
17
+ expect(isLocalizedText(true)).toBe(false);
18
+ expect(isLocalizedText(null)).toBe(false);
19
+ expect(isLocalizedText(undefined)).toBe(false);
20
+ });
21
+
22
+ test("refuses a half-written text rather than rendering an empty language", () => {
23
+ expect(
24
+ isLocalizedText({
25
+ en: "Setup",
26
+ }),
27
+ ).toBe(false);
28
+ });
29
+ });
@@ -11,6 +11,18 @@ export interface LocalizedText {
11
11
  en: string;
12
12
  }
13
13
 
14
+ export type BridgeCopy = LocalizedText | string;
15
+
16
+ export const isLocalizedText = (value: unknown): value is LocalizedText => {
17
+ if (typeof value !== "object" || value === null) {
18
+ return false;
19
+ }
20
+
21
+ const text = value as Partial<LocalizedText>;
22
+
23
+ return typeof text.ar === "string" && typeof text.en === "string";
24
+ };
25
+
14
26
  export interface BridgeOption {
15
27
  value: string;
16
28
  label: LocalizedText;
package/src/core/index.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  export * from "./bridgeAction";
2
+ export * from "./bridgeDownloadError";
2
3
  export * from "./bridgeFailure";
4
+ export * from "./bridgeHold";
3
5
  export * from "./bridgeId";
4
6
  export * from "./bridgeKind";
5
7
  export * from "./bridgeNetError";
6
8
  export * from "./bridgePort";
9
+ export * from "./bridgeProbeError";
7
10
  export * from "./bridgeRuntime";
8
11
  export * from "./bridgeSecretError";
12
+ export * from "./bridgeSetup";
9
13
  export * from "./bridgeStream";
14
+ export * from "./bridgeUserError";
10
15
  export * from "./bridgeValue";
@@ -13,6 +13,8 @@ export enum GuideOpenTab {
13
13
  Console = "console",
14
14
  Files = "files",
15
15
  Backups = "backups",
16
+ Access = "access",
17
+ Setup = "setup",
16
18
  Panel = "panel",
17
19
  }
18
20
 
@@ -51,6 +53,12 @@ export const guideOpenTargetSchema = z.discriminatedUnion("tab", [
51
53
  z.object({
52
54
  tab: z.literal(GuideOpenTab.Backups),
53
55
  }),
56
+ z.object({
57
+ tab: z.literal(GuideOpenTab.Access),
58
+ }),
59
+ z.object({
60
+ tab: z.literal(GuideOpenTab.Setup),
61
+ }),
54
62
  z.object({
55
63
  tab: z.literal(GuideOpenTab.Files),
56
64
  path: zGuideFilePath.nullable(),
@@ -132,6 +132,8 @@ describe("directives", () => {
132
132
  "@[open](backups)",
133
133
  "@[open](files:server.properties)",
134
134
  "@[open](files)",
135
+ "@[open](access)",
136
+ "@[open](setup)",
135
137
  "@[open](panel:mods)",
136
138
  ].join("\n"),
137
139
  );
@@ -163,6 +165,18 @@ describe("directives", () => {
163
165
  path: null,
164
166
  },
165
167
  },
168
+ {
169
+ type: "open",
170
+ target: {
171
+ tab: GuideOpenTab.Access,
172
+ },
173
+ },
174
+ {
175
+ type: "open",
176
+ target: {
177
+ tab: GuideOpenTab.Setup,
178
+ },
179
+ },
166
180
  {
167
181
  type: "open",
168
182
  target: {
@@ -181,6 +195,10 @@ describe("directives", () => {
181
195
  expect(() => parse("@[open](nowhere)")).toThrow(/unknown open target "nowhere"/);
182
196
  });
183
197
 
198
+ test("rejects an argument on a target that takes none", () => {
199
+ expect(() => parse("@[open](setup:sign-in)")).toThrow(/"setup" target does not take an argument/);
200
+ });
201
+
184
202
  test("rejects a traversing file path", () => {
185
203
  expect(() => parse("@[open](files:../../etc/passwd)")).toThrow();
186
204
  });
@@ -312,7 +312,13 @@ const parseOpenTarget = (path: string, line: number, value: string): GuideOpenTa
312
312
  fail(path, line, `"${tab}" target does not take an argument`);
313
313
  }
314
314
 
315
- if (tab !== GuideOpenTab.Overview && tab !== GuideOpenTab.Console && tab !== GuideOpenTab.Backups) {
315
+ if (
316
+ tab !== GuideOpenTab.Overview
317
+ && tab !== GuideOpenTab.Console
318
+ && tab !== GuideOpenTab.Backups
319
+ && tab !== GuideOpenTab.Access
320
+ && tab !== GuideOpenTab.Setup
321
+ ) {
316
322
  fail(path, line, `unknown open target "${value}"`);
317
323
  }
318
324
 
@@ -6,5 +6,6 @@ export * from "./image";
6
6
  export * from "./jsonSchema";
7
7
  export * from "./localized";
8
8
  export * from "./manifest";
9
+ export * from "./presence";
9
10
  export * from "./primitives";
10
11
  export * from "./source";
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import { PortJoin } from "../core";
4
4
  import { GameCategory } from "./category";
5
5
  import { guidePackageSchema, MANIFEST_SCHEMA_VERSION, manifestSchema, packageSchema } from "./manifest";
6
+ import { isServerkAvatarTemplate, PRESENCE_AVATAR_LENGTH_LIMIT } from "./presence";
6
7
 
7
8
  const text = (value: string) => ({
8
9
  ar: value,
@@ -586,3 +587,134 @@ describe("packageSchema", () => {
586
587
  expect(result.error && z.prettifyError(result.error)).toContain('tag "start" is not declared in guideTags');
587
588
  });
588
589
  });
590
+
591
+ describe("presence", () => {
592
+ test("is optional, so a game that declares none parses", () => {
593
+ const result = manifestSchema.safeParse(base());
594
+
595
+ expect(result.success).toBe(true);
596
+ expect(result.success && result.data.presence).toBeUndefined();
597
+ });
598
+
599
+ test("defaults the name key to player and the field list to empty", () => {
600
+ const result = manifestSchema.safeParse({
601
+ ...base(),
602
+ presence: {},
603
+ });
604
+
605
+ expect(result.success).toBe(true);
606
+ expect(result.success && result.data.presence?.name).toBe("player");
607
+ expect(result.success && result.data.presence?.fields).toEqual([]);
608
+ });
609
+
610
+ test("accepts an avatar template and localized fields", () => {
611
+ const result = manifestSchema.safeParse({
612
+ ...base(),
613
+ presence: {
614
+ name: "player",
615
+ id: "uuid",
616
+ avatar: "https://mc-heads.net/avatar/{skin}/128",
617
+ fields: [
618
+ {
619
+ key: "platform",
620
+ label: text("Platform"),
621
+ },
622
+ ],
623
+ },
624
+ });
625
+
626
+ expect(result.success).toBe(true);
627
+ expect(result.success && result.data.presence?.fields[0]?.inline).toBe(true);
628
+ });
629
+
630
+ test("accepts a serverk avatar template", () => {
631
+ const result = manifestSchema.safeParse({
632
+ ...base(),
633
+ presence: {
634
+ name: "player",
635
+ id: "playerId",
636
+ avatar: "serverk://avatars/hytale/{playerId}",
637
+ },
638
+ });
639
+
640
+ expect(result.success).toBe(true);
641
+ expect(result.success && result.data.presence?.avatar).toBe("serverk://avatars/hytale/{playerId}");
642
+ });
643
+
644
+ test("refuses an avatar template that is neither https nor a serverk avatar", () => {
645
+ const result = manifestSchema.safeParse({
646
+ ...base(),
647
+ presence: {
648
+ avatar: "http://mc-heads.net/avatar/{skin}/128",
649
+ },
650
+ });
651
+
652
+ expect(result.success).toBe(false);
653
+ expect(result.error && z.prettifyError(result.error)).toContain(
654
+ "must be an https or serverk://avatars/ url template",
655
+ );
656
+ });
657
+
658
+ test("refuses a serverk scheme that is not an avatar path", () => {
659
+ const result = manifestSchema.safeParse({
660
+ ...base(),
661
+ presence: {
662
+ avatar: "serverk://heads/hytale/{playerId}",
663
+ },
664
+ });
665
+
666
+ expect(result.success).toBe(false);
667
+ expect(result.error && z.prettifyError(result.error)).toContain(
668
+ "must be an https or serverk://avatars/ url template",
669
+ );
670
+ });
671
+
672
+ test("refuses a serverk avatar template longer than the limit", () => {
673
+ const result = manifestSchema.safeParse({
674
+ ...base(),
675
+ presence: {
676
+ avatar: `serverk://avatars/hytale/${"a".repeat(PRESENCE_AVATAR_LENGTH_LIMIT)}`,
677
+ },
678
+ });
679
+
680
+ expect(result.success).toBe(false);
681
+ });
682
+
683
+ test("tells a serverk avatar template apart from an https one", () => {
684
+ expect(isServerkAvatarTemplate("serverk://avatars/hytale/{playerId}")).toBe(true);
685
+ expect(isServerkAvatarTemplate("https://mc-heads.net/avatar/{skin}/128")).toBe(false);
686
+ });
687
+
688
+ test("refuses a payload key that could never be a capture group", () => {
689
+ const result = manifestSchema.safeParse({
690
+ ...base(),
691
+ presence: {
692
+ name: "player name",
693
+ },
694
+ });
695
+
696
+ expect(result.success).toBe(false);
697
+ expect(result.error && z.prettifyError(result.error)).toContain("must be a valid capture group name");
698
+ });
699
+
700
+ test("refuses the same field declared twice", () => {
701
+ const result = manifestSchema.safeParse({
702
+ ...base(),
703
+ presence: {
704
+ fields: [
705
+ {
706
+ key: "platform",
707
+ label: text("Platform"),
708
+ },
709
+ {
710
+ key: "platform",
711
+ label: text("Platform again"),
712
+ },
713
+ ],
714
+ },
715
+ });
716
+
717
+ expect(result.success).toBe(false);
718
+ expect(result.error && z.prettifyError(result.error)).toContain('"platform" is declared more than once');
719
+ });
720
+ });
@@ -6,6 +6,7 @@ import { GameCategory } from "./category";
6
6
  import { type GuideEntry, type GuideTag, guideEntrySchema, guideTagSchema } from "./guide";
7
7
  import { IMAGE_NAMESPACE } from "./image";
8
8
  import { localizedText } from "./localized";
9
+ import { type ManifestPresence, manifestPresenceSchema } from "./presence";
9
10
  import {
10
11
  HOST_RANGE_MAX_SIZE,
11
12
  zAssetPath,
@@ -205,6 +206,24 @@ const duplicateIssues = <Item>(
205
206
  }));
206
207
  };
207
208
 
209
+ const presenceIssues = (presence: ManifestPresence | undefined): CheckIssue[] => {
210
+ if (!presence) {
211
+ return [];
212
+ }
213
+
214
+ return duplicateIssues(
215
+ presence.fields,
216
+ (field) => field.key,
217
+ (index) => [
218
+ "presence",
219
+ "fields",
220
+ index,
221
+ "key",
222
+ ],
223
+ (key) => `"${key}" is declared more than once`,
224
+ );
225
+ };
226
+
208
227
  const hostRangesOverlap = (a: ManifestPort, b: ManifestPort) => {
209
228
  return a.hostRange.min <= b.hostRange.max && b.hostRange.min <= a.hostRange.max;
210
229
  };
@@ -355,6 +374,7 @@ export const manifestSchema = z
355
374
  reset: manifestResetSchema.prefault({}),
356
375
  secrets: z.array(manifestSecretSchema).max(16).default([]),
357
376
  files: manifestFilesSchema.prefault({}),
377
+ presence: manifestPresenceSchema.optional(),
358
378
  })
359
379
  .check((ctx) => {
360
380
  if (ctx.issues.length > 0) {
@@ -410,6 +430,7 @@ export const manifestSchema = z
410
430
  (key) => `"${key}" already has a note`,
411
431
  ),
412
432
  ...guideIssues(manifest.guideTags, manifest.guides),
433
+ ...presenceIssues(manifest.presence),
413
434
  );
414
435
  });
415
436
 
@@ -0,0 +1,56 @@
1
+ import { z } from "zod";
2
+ import { localizedText } from "./localized";
3
+ import { zPayloadKey } from "./primitives";
4
+
5
+ export const PRESENCE_FIELDS_LIMIT = 6;
6
+
7
+ export const PRESENCE_AVATAR_LENGTH_LIMIT = 300;
8
+
9
+ export const SERVERK_AVATAR_PREFIX = "serverk://avatars/";
10
+
11
+ export const PRESENCE_AVATAR_PATTERN = /^(https:\/\/|serverk:\/\/avatars\/)/;
12
+
13
+ export const isServerkAvatarTemplate = (template: string): boolean => {
14
+ return template.startsWith(SERVERK_AVATAR_PREFIX);
15
+ };
16
+
17
+ export const manifestPresenceFieldSchema = z.object({
18
+ key: zPayloadKey,
19
+ label: localizedText(32),
20
+ inline: z.boolean().default(true),
21
+ });
22
+
23
+ export type ManifestPresenceField = z.infer<typeof manifestPresenceFieldSchema>;
24
+
25
+ export const manifestPresenceSchema = z.object({
26
+ name: zPayloadKey.default("player"),
27
+ id: zPayloadKey.optional(),
28
+ avatar: z
29
+ .string()
30
+ .max(PRESENCE_AVATAR_LENGTH_LIMIT)
31
+ .regex(PRESENCE_AVATAR_PATTERN, "must be an https or serverk://avatars/ url template")
32
+ .optional(),
33
+ fields: z.array(manifestPresenceFieldSchema).max(PRESENCE_FIELDS_LIMIT).default([]),
34
+ });
35
+
36
+ export type ManifestPresence = z.infer<typeof manifestPresenceSchema>;
37
+
38
+ export const PRESENCE_PLACEHOLDER = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
39
+
40
+ export const presencePlaceholders = (template: string): string[] => {
41
+ PRESENCE_PLACEHOLDER.lastIndex = 0;
42
+
43
+ const names: string[] = [];
44
+
45
+ let match = PRESENCE_PLACEHOLDER.exec(template);
46
+
47
+ while (match) {
48
+ if (match[1]) {
49
+ names.push(match[1]);
50
+ }
51
+
52
+ match = PRESENCE_PLACEHOLDER.exec(template);
53
+ }
54
+
55
+ return names;
56
+ };
@@ -26,6 +26,12 @@ export const zEnvKey = z
26
26
  .max(32)
27
27
  .regex(/^[A-Z][A-Z0-9_]*$/, "must be SCREAMING_SNAKE_CASE, starting with a letter");
28
28
 
29
+ export const zPayloadKey = z
30
+ .string()
31
+ .min(1)
32
+ .max(64)
33
+ .regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "must be a valid capture group name, starting with a letter or underscore");
34
+
29
35
  export const zImageReference = z.templateLiteral([
30
36
  `${IMAGE_NAMESPACE}/`,
31
37
  zSlug,