@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.
- package/package.json +3 -3
- package/src/cli/validate.ts +2 -0
- package/src/context/bridgeContext.ts +17 -0
- package/src/context/index.ts +1 -0
- package/src/core/bridgeDownloadError.ts +10 -0
- package/src/core/bridgeHold.ts +12 -0
- package/src/core/bridgeKind.ts +3 -0
- package/src/core/bridgeProbeError.ts +16 -0
- package/src/core/bridgeSetup.ts +20 -0
- package/src/core/bridgeUserError.test.ts +23 -0
- package/src/core/bridgeUserError.ts +12 -0
- package/src/core/bridgeValue.test.ts +29 -0
- package/src/core/bridgeValue.ts +12 -0
- package/src/core/index.ts +5 -0
- package/src/guides/blocks.ts +8 -0
- package/src/guides/markdown.test.ts +18 -0
- package/src/guides/markdown.ts +7 -1
- package/src/manifest/index.ts +1 -0
- package/src/manifest/manifest.test.ts +132 -0
- package/src/manifest/manifest.ts +21 -0
- package/src/manifest/presence.ts +56 -0
- package/src/manifest/primitives.ts +6 -0
- package/src/modules/bridge.ts +65 -2
- package/src/modules/bridgeDriver.ts +4 -0
- package/src/modules/bridgeModule.ts +32 -3
- package/src/panel/bridgeDetail.ts +49 -0
- package/src/panel/bridgeLayout.ts +1 -0
- package/src/panel/bridgePanel.ts +30 -0
- package/src/panel/bridgeUploadMode.ts +4 -0
- package/src/panel/index.ts +2 -0
- package/src/probes/a2s.test.ts +71 -0
- package/src/probes/a2s.ts +38 -11
- package/src/protocol/bridgeEgress.ts +8 -1
- package/src/protocol/bridgeEvents.ts +4 -0
- package/src/protocol/bridgeFrame.ts +33 -1
- package/src/protocol/bridgeLimits.ts +12 -0
- package/src/protocol/bridgeManifest.ts +2 -0
- package/src/protocol/bridgeSetup.ts +97 -0
- package/src/protocol/index.ts +1 -0
- package/src/runtime/bridgeActions.ts +7 -0
- package/src/runtime/bridgeConfigReader.ts +12 -8
- package/src/runtime/bridgeContext.ts +28 -2
- package/src/runtime/bridgeDriver.test.ts +748 -3
- package/src/runtime/bridgeDriver.ts +198 -4
- package/src/runtime/bridgeEgress.ts +3 -0
- package/src/runtime/bridgeEvents.test.ts +96 -0
- package/src/runtime/bridgeEvents.ts +11 -6
- package/src/runtime/bridgeFiles.test.ts +625 -15
- package/src/runtime/bridgeFiles.ts +152 -45
- package/src/runtime/bridgeInstall.test.ts +118 -0
- package/src/runtime/bridgeInstall.ts +55 -0
- package/src/runtime/bridgeNet.ts +32 -3
- package/src/runtime/bridgePending.test.ts +148 -0
- package/src/runtime/bridgePending.ts +43 -0
- package/src/runtime/bridgeRegistry.test.ts +172 -1
- package/src/runtime/bridgeRegistry.ts +40 -5
- package/src/runtime/bridgeSecrets.test.ts +121 -0
- package/src/runtime/bridgeSecrets.ts +43 -0
- package/src/runtime/bridgeServer.test.ts +598 -0
- package/src/runtime/bridgeServer.ts +42 -3
- package/src/runtime/bridgeSetup.test.ts +189 -0
- package/src/runtime/bridgeSetup.ts +63 -0
- package/src/runtime/bridgeSignal.test.ts +114 -0
- package/src/runtime/bridgeSignal.ts +31 -0
- package/src/runtime/bridgeSupervisor.test.ts +111 -0
- package/src/runtime/bridgeSupervisor.ts +55 -5
- package/src/runtime/bridgeZip.ts +179 -58
- package/src/runtime/serve.ts +115 -4
- package/src/utils/index.ts +1 -0
- package/src/utils/stripAnsi.ts +12 -0
- package/src/wire/panel.test.ts +259 -0
- 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.
|
|
4
|
-
"description": "Serverk game bridge
|
|
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
|
|
32
|
+
"zod": "4.5.4"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/src/cli/validate.ts
CHANGED
|
@@ -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
|
}
|
package/src/context/index.ts
CHANGED
package/src/core/bridgeKind.ts
CHANGED
|
@@ -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,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
|
+
});
|
package/src/core/bridgeValue.ts
CHANGED
|
@@ -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";
|
package/src/guides/blocks.ts
CHANGED
|
@@ -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
|
});
|
package/src/guides/markdown.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
package/src/manifest/index.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/manifest/manifest.ts
CHANGED
|
@@ -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,
|