@serverkgg/bridge 0.0.1 → 0.0.2

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 (59) hide show
  1. package/package.json +10 -3
  2. package/src/cli/main.ts +11 -1
  3. package/src/cli/schema.ts +62 -0
  4. package/src/cli/validate.ts +119 -36
  5. package/src/context/bridgeContext.ts +11 -1
  6. package/src/core/bridgeAction.ts +13 -0
  7. package/src/core/bridgeFailure.ts +16 -0
  8. package/src/core/bridgeKind.ts +2 -0
  9. package/src/core/bridgeNetError.ts +10 -0
  10. package/src/core/index.ts +2 -0
  11. package/src/guides/blocks.ts +167 -0
  12. package/src/guides/compile.ts +193 -0
  13. package/src/guides/compiled.ts +28 -0
  14. package/src/guides/index.ts +6 -0
  15. package/src/guides/markdown.test.ts +374 -0
  16. package/src/guides/markdown.ts +769 -0
  17. package/src/guides/variables.ts +52 -0
  18. package/src/guides/version.ts +1 -0
  19. package/src/main.ts +2 -1
  20. package/src/manifest/assets.ts +4 -4
  21. package/src/manifest/guide.ts +23 -18
  22. package/src/manifest/index.ts +3 -2
  23. package/src/manifest/jsonSchema.ts +16 -0
  24. package/src/manifest/localized.ts +7 -0
  25. package/src/manifest/manifest.test.ts +366 -0
  26. package/src/manifest/manifest.ts +354 -120
  27. package/src/manifest/primitives.ts +63 -0
  28. package/src/manifest/source.ts +5 -4
  29. package/src/modules/bridge.ts +19 -3
  30. package/src/modules/bridgeDriver.ts +4 -0
  31. package/src/modules/bridgeModule.ts +22 -24
  32. package/src/panel/bridgeAccess.ts +3 -0
  33. package/src/panel/bridgePanel.ts +6 -0
  34. package/src/panel/index.ts +1 -0
  35. package/src/protocol/bridgeCatalog.ts +56 -0
  36. package/src/protocol/bridgeEgress.ts +53 -0
  37. package/src/protocol/bridgeFrame.ts +17 -1
  38. package/src/protocol/bridgeLine.ts +41 -6
  39. package/src/protocol/bridgeManifest.ts +15 -1
  40. package/src/protocol/bridgeVersion.ts +1 -1
  41. package/src/protocol/index.ts +3 -0
  42. package/src/runtime/bridgeContext.ts +29 -9
  43. package/src/runtime/bridgeDriver.ts +1 -0
  44. package/src/runtime/bridgeEgress.ts +360 -0
  45. package/src/runtime/bridgeExec.ts +15 -5
  46. package/src/runtime/bridgeFiles.ts +58 -21
  47. package/src/runtime/bridgeLog.ts +26 -3
  48. package/src/runtime/bridgeNet.ts +75 -22
  49. package/src/runtime/bridgeRegistry.ts +51 -6
  50. package/src/runtime/bridgeServer.ts +48 -1
  51. package/src/runtime/bridgeSupervisor.ts +6 -6
  52. package/src/runtime/index.ts +0 -17
  53. package/src/runtime/serve.ts +44 -3
  54. package/src/terminal/bridgeTerminalArg.ts +11 -0
  55. package/src/terminal/bridgeTerminalCommand.ts +11 -0
  56. package/src/terminal/bridgeTerminalLevel.ts +6 -0
  57. package/src/terminal/bridgeTerminalRule.ts +8 -0
  58. package/src/terminal/index.ts +4 -0
  59. package/src/manifest/checks.ts +0 -50
@@ -0,0 +1,52 @@
1
+ import { z } from "zod";
2
+
3
+ export enum GuideVariableFormat {
4
+ Text = "text",
5
+ Code = "code",
6
+ Number = "number",
7
+ DateTime = "datetime",
8
+ }
9
+
10
+ export const GUIDE_VARIABLES = {
11
+ "server.name": GuideVariableFormat.Text,
12
+ "server.code": GuideVariableFormat.Code,
13
+ "server.address": GuideVariableFormat.Code,
14
+ "server.host": GuideVariableFormat.Code,
15
+ "server.port": GuideVariableFormat.Code,
16
+ "server.game": GuideVariableFormat.Text,
17
+ "server.region": GuideVariableFormat.Text,
18
+ "server.state": GuideVariableFormat.Text,
19
+ "server.version": GuideVariableFormat.Code,
20
+ "server.memory": GuideVariableFormat.Number,
21
+ "server.disk": GuideVariableFormat.Number,
22
+ "server.playersOnline": GuideVariableFormat.Number,
23
+ "server.playersMax": GuideVariableFormat.Number,
24
+ "server.expiresAt": GuideVariableFormat.DateTime,
25
+ "user.username": GuideVariableFormat.Text,
26
+ } as const satisfies Record<string, GuideVariableFormat>;
27
+
28
+ export type GuideVariableKey = keyof typeof GUIDE_VARIABLES;
29
+
30
+ export const GUIDE_VARIABLE_KEYS = Object.keys(GUIDE_VARIABLES) as GuideVariableKey[];
31
+
32
+ const PANEL_VARIABLE_PATTERN = /^panel\.[A-Z][A-Z0-9_]*$/;
33
+
34
+ export const isGuideVariableKey = (value: string): value is GuideVariableKey => {
35
+ return value in GUIDE_VARIABLES;
36
+ };
37
+
38
+ export const isPanelVariableKey = (value: string) => {
39
+ return PANEL_VARIABLE_PATTERN.test(value);
40
+ };
41
+
42
+ export const guideVariableFormat = (key: string) => {
43
+ return isGuideVariableKey(key) ? GUIDE_VARIABLES[key] : GuideVariableFormat.Text;
44
+ };
45
+
46
+ export const zGuideVariableRef = z
47
+ .string()
48
+ .min(1)
49
+ .max(64)
50
+ .refine((value) => isGuideVariableKey(value) || isPanelVariableKey(value), {
51
+ error: `unknown variable; use one of ${GUIDE_VARIABLE_KEYS.join(", ")} or panel.SOME_KEY`,
52
+ });
@@ -0,0 +1 @@
1
+ export const GUIDE_COMPILER_VERSION = "2";
package/src/main.ts CHANGED
@@ -2,4 +2,5 @@ export * from "./context";
2
2
  export * from "./core";
3
3
  export * from "./modules";
4
4
  export * from "./panel";
5
- export { BridgeNetError, serve } from "./runtime";
5
+ export * from "./runtime";
6
+ export * from "./terminal";
@@ -1,10 +1,10 @@
1
1
  import { ASSET_MAX_EDGE, type AssetDimensions, readDimensions } from "./dimensions";
2
2
 
3
- export const ASSET_MAX_BYTES = 512 * 1024;
3
+ const ASSET_MAX_BYTES = 512 * 1024;
4
4
 
5
- export const ASSET_EXTENSION = "webp";
5
+ const ASSET_EXTENSION = "webp";
6
6
 
7
- export const ASSET_MEDIA_TYPE = "image/webp";
7
+ const ASSET_MEDIA_TYPE = "image/webp";
8
8
 
9
9
  const RIFF_HEADER_BYTES = 12;
10
10
 
@@ -55,7 +55,7 @@ const isWebp = (bytes: Uint8Array) => {
55
55
  return declared + 8 === bytes.byteLength;
56
56
  };
57
57
 
58
- export const assetMediaType = (path: string) => {
58
+ const assetMediaType = (path: string) => {
59
59
  const extension = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
60
60
 
61
61
  if (extension !== ASSET_EXTENSION) {
@@ -1,33 +1,38 @@
1
1
  import { z } from "zod";
2
- import { localizedTextSchema } from "./localized";
3
-
4
- export const zGuideId = z
5
- .string()
6
- .min(1)
7
- .max(64)
8
- .regex(/^[a-z0-9][a-z0-9-]*$/);
2
+ import { localizedText } from "./localized";
3
+ import { zMarkdownPath, zSlug } from "./primitives";
9
4
 
10
5
  export const zGuideTag = z
11
6
  .string()
12
7
  .min(1)
13
8
  .max(32)
14
- .regex(/^[a-z0-9][a-z0-9._-]*$/);
9
+ .regex(/^[a-z0-9][a-z0-9._-]*$/, "must be lowercase letters, digits, dots, dashes and underscores");
15
10
 
16
- export const zYoutubeId = z.string().regex(/^[A-Za-z0-9_-]{11}$/);
11
+ export const zYoutubeId = z.string().regex(/^[A-Za-z0-9_-]{11}$/, "must be an 11-character YouTube video id");
17
12
 
18
- const zMarkdownPath = z
19
- .string()
20
- .min(1)
21
- .max(256)
22
- .regex(/^[a-zA-Z0-9][a-zA-Z0-9/_-]*\.md$/);
13
+ export enum GuideSurface {
14
+ Overview = "overview",
15
+ Console = "console",
16
+ Files = "files",
17
+ Backups = "backups",
18
+ Panel = "panel",
19
+ }
20
+
21
+ export const guideTagSchema = z.object({
22
+ id: zGuideTag,
23
+ name: localizedText(48),
24
+ });
25
+
26
+ export type GuideTag = z.infer<typeof guideTagSchema>;
23
27
 
24
28
  export const guideEntrySchema = z.object({
25
- id: zGuideId,
26
- title: localizedTextSchema,
27
- summary: localizedTextSchema,
29
+ id: zSlug,
30
+ title: localizedText(120),
31
+ summary: localizedText(300),
28
32
  tags: z.array(zGuideTag).max(8).default([]),
33
+ surfaces: z.array(z.enum(GuideSurface)).max(8).default([]),
29
34
  video: zYoutubeId.optional(),
30
- body: z.object({
35
+ bodyPath: z.object({
31
36
  ar: zMarkdownPath,
32
37
  en: zMarkdownPath,
33
38
  }),
@@ -1,9 +1,10 @@
1
+ export * from "../utils";
1
2
  export * from "./assets";
2
3
  export * from "./category";
3
- export * from "./checks";
4
- export * from "./dimensions";
5
4
  export * from "./guide";
6
5
  export * from "./image";
6
+ export * from "./jsonSchema";
7
7
  export * from "./localized";
8
8
  export * from "./manifest";
9
+ export * from "./primitives";
9
10
  export * from "./source";
@@ -0,0 +1,16 @@
1
+ import { z } from "zod";
2
+ import { packageSchema } from "./manifest";
3
+
4
+ export const SCHEMA_FILE = "serverk.schema.json";
5
+
6
+ export const buildJsonSchema = () => {
7
+ return z.toJSONSchema(packageSchema, {
8
+ io: "input",
9
+ target: "draft-2020-12",
10
+ unrepresentable: "any",
11
+ });
12
+ };
13
+
14
+ export const renderJsonSchema = () => {
15
+ return `${JSON.stringify(buildJsonSchema(), null, "\t")}\n`;
16
+ };
@@ -6,3 +6,10 @@ export const localizedTextSchema = z.object({
6
6
  });
7
7
 
8
8
  export type LocalizedText = z.infer<typeof localizedTextSchema>;
9
+
10
+ export const localizedText = (max: number) => {
11
+ return z.object({
12
+ ar: z.string().trim().min(1, "is required in Arabic").max(max),
13
+ en: z.string().trim().min(1, "is required in English").max(max),
14
+ });
15
+ };
@@ -0,0 +1,366 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { GameCategory } from "./category";
4
+ import { guidePackageSchema, MANIFEST_SCHEMA_VERSION, manifestSchema, packageSchema } from "./manifest";
5
+
6
+ const text = (value: string) => ({
7
+ ar: value,
8
+ en: value,
9
+ });
10
+
11
+ const base = () => ({
12
+ schemaVersion: MANIFEST_SCHEMA_VERSION,
13
+ kind: "GamePackage",
14
+ metadata: {
15
+ id: "minecraft",
16
+ name: text("Minecraft"),
17
+ description: text("A game"),
18
+ tagline: text("Play now"),
19
+ category: GameCategory.Sandbox,
20
+ logo: "assets/logo.webp",
21
+ banner: "assets/banner.webp",
22
+ },
23
+ resources: {
24
+ minMemoryMb: 1024,
25
+ recommendedMemoryMb: 4096,
26
+ minDiskMb: 5120,
27
+ },
28
+ container: {
29
+ image: "serverkgg/minecraft",
30
+ ports: [
31
+ {
32
+ key: "game",
33
+ containerPort: 25_565,
34
+ protocol: "tcp",
35
+ hostRange: {
36
+ default: 25_565,
37
+ min: 25_565,
38
+ max: 25_664,
39
+ },
40
+ },
41
+ ],
42
+ },
43
+ });
44
+
45
+ const failure = (input: unknown) => {
46
+ const result = manifestSchema.safeParse(input);
47
+
48
+ if (result.success) {
49
+ throw new Error("expected the manifest to be rejected");
50
+ }
51
+
52
+ return result.error.issues.map((issue) => ({
53
+ path: issue.path.join("."),
54
+ message: issue.message,
55
+ }));
56
+ };
57
+
58
+ describe("defaults", () => {
59
+ test("fills in the optional sections so consumers never guard", () => {
60
+ const manifest = manifestSchema.parse(base());
61
+
62
+ expect(manifest.backup).toEqual({
63
+ only: [],
64
+ except: [],
65
+ });
66
+ expect(manifest.reset).toEqual({
67
+ keep: [],
68
+ });
69
+ expect(manifest.files).toEqual({
70
+ protected: [],
71
+ notable: [],
72
+ });
73
+ expect(manifest.guides).toEqual([]);
74
+ expect(manifest.secrets).toEqual([]);
75
+ });
76
+ });
77
+
78
+ describe("cross-entry checks", () => {
79
+ test("rejects an image that does not match metadata.id", () => {
80
+ const manifest = base();
81
+
82
+ manifest.container.image = "serverkgg/minecraft-java";
83
+
84
+ expect(failure(manifest)).toEqual([
85
+ {
86
+ path: "container.image",
87
+ message: 'must be "serverkgg/minecraft" to match metadata.id',
88
+ },
89
+ ]);
90
+ });
91
+
92
+ test("rejects duplicate secret keys", () => {
93
+ const manifest = {
94
+ ...base(),
95
+ secrets: [
96
+ {
97
+ key: "MODRINTH_API_KEY",
98
+ description: text("A key"),
99
+ },
100
+ {
101
+ key: "MODRINTH_API_KEY",
102
+ description: text("The same key"),
103
+ },
104
+ ],
105
+ };
106
+
107
+ expect(failure(manifest)).toEqual([
108
+ {
109
+ path: "secrets.1.key",
110
+ message: 'secret "MODRINTH_API_KEY" is declared twice',
111
+ },
112
+ ]);
113
+ });
114
+
115
+ test("rejects duplicate protected paths and notable matches", () => {
116
+ const manifest = {
117
+ ...base(),
118
+ files: {
119
+ protected: [
120
+ "eula.txt",
121
+ "eula.txt",
122
+ ],
123
+ notable: [
124
+ {
125
+ match: "server.properties",
126
+ label: text("Settings"),
127
+ note: text("Core settings"),
128
+ },
129
+ {
130
+ match: "server.properties",
131
+ label: text("Settings again"),
132
+ note: text("Core settings"),
133
+ },
134
+ ],
135
+ },
136
+ };
137
+
138
+ expect(failure(manifest)).toEqual([
139
+ {
140
+ path: "files.protected.1",
141
+ message: '"eula.txt" is protected twice',
142
+ },
143
+ {
144
+ path: "files.notable.1.match",
145
+ message: '"server.properties" already has a note',
146
+ },
147
+ ]);
148
+ });
149
+
150
+ test("rejects duplicate port keys and overlapping host ranges", () => {
151
+ const manifest = base();
152
+ const [game] = manifest.container.ports;
153
+
154
+ if (!game) {
155
+ throw new Error("fixture is missing its port");
156
+ }
157
+
158
+ manifest.container.ports.push({
159
+ ...game,
160
+ hostRange: {
161
+ default: 25_600,
162
+ min: 25_600,
163
+ max: 25_700,
164
+ },
165
+ });
166
+
167
+ expect(failure(manifest)).toEqual([
168
+ {
169
+ path: "container.ports.1.key",
170
+ message: 'port "game" is declared twice',
171
+ },
172
+ {
173
+ path: "container.ports.1.hostRange",
174
+ message: 'host range overlaps port "game" — two tcp ports cannot share host ports',
175
+ },
176
+ ]);
177
+ });
178
+
179
+ test("rejects a guide tag that is not declared", () => {
180
+ const manifest = {
181
+ ...base(),
182
+ guides: [
183
+ {
184
+ id: "getting-started",
185
+ title: text("Getting started"),
186
+ summary: text("Start here"),
187
+ tags: [
188
+ "start",
189
+ ],
190
+ bodyPath: {
191
+ ar: "guides/getting-started/ar.md",
192
+ en: "guides/getting-started/en.md",
193
+ },
194
+ },
195
+ ],
196
+ };
197
+
198
+ expect(failure(manifest)).toEqual([
199
+ {
200
+ path: "guides.0.tags.0",
201
+ message: 'tag "start" is not declared in guideTags',
202
+ },
203
+ ]);
204
+ });
205
+
206
+ test("rejects one body file serving both locales", () => {
207
+ const manifest = {
208
+ ...base(),
209
+ guides: [
210
+ {
211
+ id: "getting-started",
212
+ title: text("Getting started"),
213
+ summary: text("Start here"),
214
+ bodyPath: {
215
+ ar: "guides/getting-started/en.md",
216
+ en: "guides/getting-started/en.md",
217
+ },
218
+ },
219
+ ],
220
+ };
221
+
222
+ expect(failure(manifest)).toEqual([
223
+ {
224
+ path: "guides.0.bodyPath",
225
+ message: 'guide "getting-started" reuses one body file for both locales',
226
+ },
227
+ ]);
228
+ });
229
+ });
230
+
231
+ describe("field validation", () => {
232
+ test("explains a bad host range instead of saying nothing", () => {
233
+ const manifest = base();
234
+ const [game] = manifest.container.ports;
235
+
236
+ if (!game) {
237
+ throw new Error("fixture is missing its port");
238
+ }
239
+
240
+ game.hostRange.min = 25_700;
241
+
242
+ expect(failure(manifest)).toEqual([
243
+ {
244
+ path: "container.ports.0.hostRange",
245
+ message: "min must not be greater than max",
246
+ },
247
+ {
248
+ path: "container.ports.0.hostRange",
249
+ message: "default must fall inside min..max",
250
+ },
251
+ ]);
252
+ });
253
+
254
+ test("explains a default that sits outside its range", () => {
255
+ const manifest = base();
256
+ const [game] = manifest.container.ports;
257
+
258
+ if (!game) {
259
+ throw new Error("fixture is missing its port");
260
+ }
261
+
262
+ game.hostRange.default = 30_000;
263
+
264
+ expect(failure(manifest)).toEqual([
265
+ {
266
+ path: "container.ports.0.hostRange",
267
+ message: "default must fall inside min..max",
268
+ },
269
+ ]);
270
+ });
271
+
272
+ test("explains a bad colour", () => {
273
+ const manifest = {
274
+ ...base(),
275
+ files: {
276
+ notable: [
277
+ {
278
+ match: "server.properties",
279
+ label: text("Settings"),
280
+ note: text("Core settings"),
281
+ color: "green",
282
+ },
283
+ ],
284
+ },
285
+ };
286
+
287
+ expect(failure(manifest)).toEqual([
288
+ {
289
+ path: "files.notable.0.color",
290
+ message: "must be a six-digit hex colour like #4a8fd4",
291
+ },
292
+ ]);
293
+ });
294
+
295
+ test("field failures short-circuit the cross-entry checks", () => {
296
+ const manifest = base();
297
+
298
+ manifest.metadata.id = "Minecraft";
299
+ manifest.container.image = "serverkgg/minecraft";
300
+
301
+ const issues = failure(manifest);
302
+
303
+ expect(issues).toEqual([
304
+ {
305
+ path: "metadata.id",
306
+ message: "must be lowercase letters, digits and dashes, starting with a letter or digit",
307
+ },
308
+ ]);
309
+ });
310
+ });
311
+
312
+ describe("packageSchema", () => {
313
+ test("routes on kind without a separate pre-parse", () => {
314
+ const game = packageSchema.parse(base());
315
+
316
+ expect(game.kind).toBe("GamePackage");
317
+
318
+ const guides = packageSchema.parse({
319
+ schemaVersion: MANIFEST_SCHEMA_VERSION,
320
+ kind: "GuidePackage",
321
+ metadata: {
322
+ id: "platform",
323
+ },
324
+ guides: [
325
+ {
326
+ id: "getting-started",
327
+ title: text("Getting started"),
328
+ summary: text("Start here"),
329
+ bodyPath: {
330
+ ar: "guides/getting-started/ar.md",
331
+ en: "guides/getting-started/en.md",
332
+ },
333
+ },
334
+ ],
335
+ });
336
+
337
+ expect(guides.kind).toBe("GuidePackage");
338
+ });
339
+
340
+ test("guide packages get the same guide checks", () => {
341
+ const result = guidePackageSchema.safeParse({
342
+ schemaVersion: MANIFEST_SCHEMA_VERSION,
343
+ kind: "GuidePackage",
344
+ metadata: {
345
+ id: "platform",
346
+ },
347
+ guides: [
348
+ {
349
+ id: "getting-started",
350
+ title: text("Getting started"),
351
+ summary: text("Start here"),
352
+ tags: [
353
+ "start",
354
+ ],
355
+ bodyPath: {
356
+ ar: "guides/getting-started/ar.md",
357
+ en: "guides/getting-started/en.md",
358
+ },
359
+ },
360
+ ],
361
+ });
362
+
363
+ expect(result.success).toBe(false);
364
+ expect(result.error && z.prettifyError(result.error)).toContain('tag "start" is not declared in guideTags');
365
+ });
366
+ });