@serverkgg/bridge 0.0.1

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 (73) hide show
  1. package/LICENSE.md +22 -0
  2. package/README.md +52 -0
  3. package/package.json +26 -0
  4. package/src/cli/compile.ts +75 -0
  5. package/src/cli/directorySource.ts +48 -0
  6. package/src/cli/main.ts +44 -0
  7. package/src/cli/validate.ts +94 -0
  8. package/src/codecs/index.ts +3 -0
  9. package/src/codecs/json.ts +42 -0
  10. package/src/codecs/properties.ts +75 -0
  11. package/src/codecs/ueIni.ts +235 -0
  12. package/src/context/bridgeContext.ts +120 -0
  13. package/src/context/index.ts +1 -0
  14. package/src/core/bridgeAction.ts +13 -0
  15. package/src/core/bridgeId.ts +3 -0
  16. package/src/core/bridgeKind.ts +11 -0
  17. package/src/core/bridgePort.ts +4 -0
  18. package/src/core/bridgeRuntime.ts +13 -0
  19. package/src/core/bridgeSecretError.ts +10 -0
  20. package/src/core/bridgeStream.ts +4 -0
  21. package/src/core/bridgeValue.ts +19 -0
  22. package/src/core/index.ts +8 -0
  23. package/src/main.ts +5 -0
  24. package/src/manifest/assets.ts +102 -0
  25. package/src/manifest/category.ts +10 -0
  26. package/src/manifest/checks.ts +50 -0
  27. package/src/manifest/dimensions.ts +106 -0
  28. package/src/manifest/guide.ts +36 -0
  29. package/src/manifest/image.ts +1 -0
  30. package/src/manifest/index.ts +9 -0
  31. package/src/manifest/localized.ts +8 -0
  32. package/src/manifest/manifest.ts +170 -0
  33. package/src/manifest/source.ts +89 -0
  34. package/src/modules/bridge.ts +53 -0
  35. package/src/modules/bridgeDriver.ts +19 -0
  36. package/src/modules/bridgeModule.ts +114 -0
  37. package/src/modules/index.ts +3 -0
  38. package/src/panel/bridgeConfirm.ts +5 -0
  39. package/src/panel/bridgeControl.ts +7 -0
  40. package/src/panel/bridgeFormTarget.ts +4 -0
  41. package/src/panel/bridgeIcon.ts +17 -0
  42. package/src/panel/bridgeLayout.ts +6 -0
  43. package/src/panel/bridgePanel.ts +107 -0
  44. package/src/panel/index.ts +6 -0
  45. package/src/probes/a2s.ts +170 -0
  46. package/src/probes/index.ts +1 -0
  47. package/src/protocol/bridgeConfig.ts +13 -0
  48. package/src/protocol/bridgeFrame.ts +91 -0
  49. package/src/protocol/bridgeLine.ts +40 -0
  50. package/src/protocol/bridgeManifest.ts +19 -0
  51. package/src/protocol/bridgeVersion.ts +11 -0
  52. package/src/protocol/index.ts +7 -0
  53. package/src/runtime/bridgeCodec.ts +78 -0
  54. package/src/runtime/bridgeConfigReader.ts +119 -0
  55. package/src/runtime/bridgeContext.ts +96 -0
  56. package/src/runtime/bridgeDriver.ts +179 -0
  57. package/src/runtime/bridgeEvents.ts +71 -0
  58. package/src/runtime/bridgeExec.ts +143 -0
  59. package/src/runtime/bridgeFiles.ts +412 -0
  60. package/src/runtime/bridgeLog.ts +15 -0
  61. package/src/runtime/bridgeNet.ts +59 -0
  62. package/src/runtime/bridgePath.ts +132 -0
  63. package/src/runtime/bridgePrivilege.ts +99 -0
  64. package/src/runtime/bridgeProbe.ts +14 -0
  65. package/src/runtime/bridgeRegistry.ts +311 -0
  66. package/src/runtime/bridgeServer.ts +233 -0
  67. package/src/runtime/bridgeState.ts +40 -0
  68. package/src/runtime/bridgeSupervisor.ts +498 -0
  69. package/src/runtime/bridgeZip.ts +122 -0
  70. package/src/runtime/index.ts +18 -0
  71. package/src/runtime/serve.ts +294 -0
  72. package/src/utils/glob.ts +62 -0
  73. package/src/utils/index.ts +1 -0
@@ -0,0 +1,96 @@
1
+ import type { BridgeContext } from "../context";
2
+ import type { BridgeValues } from "../core";
3
+ import type { BridgeConfig } from "../protocol";
4
+ import { createBridgeCodecs } from "./bridgeCodec";
5
+ import { createBridgeExec } from "./bridgeExec";
6
+ import { createBridgeFiles } from "./bridgeFiles";
7
+ import type { BridgeLogger } from "./bridgeLog";
8
+ import { createBridgeNet } from "./bridgeNet";
9
+ import { createBridgeProbes } from "./bridgeProbe";
10
+ import type { BridgeSupervisor } from "./bridgeSupervisor";
11
+
12
+ export interface ContextHooks {
13
+ onLog: BridgeLogger;
14
+ onEmit: (event: string, payload?: BridgeValues) => void;
15
+ }
16
+
17
+ export const createContext = (
18
+ config: BridgeConfig,
19
+ supervisor: BridgeSupervisor,
20
+ hooks: ContextHooks,
21
+ ): BridgeContext => {
22
+ const files = createBridgeFiles({
23
+ root: config.cwd,
24
+ uid: config.gameUid,
25
+ gid: config.gameGid,
26
+ onLog: hooks.onLog,
27
+ });
28
+
29
+ return {
30
+ server: {
31
+ code: config.code,
32
+ memoryMb: config.memoryMb,
33
+ cpuPercent: config.cpuPercent,
34
+ get running() {
35
+ return supervisor.running;
36
+ },
37
+ },
38
+
39
+ variable(key) {
40
+ return config.variables[key] ?? null;
41
+ },
42
+
43
+ secret(key) {
44
+ return config.secrets[key] ?? null;
45
+ },
46
+
47
+ port(key) {
48
+ const value = config.ports[key];
49
+
50
+ if (value === undefined) {
51
+ throw new Error(`this game declares no port named "${key}"`);
52
+ }
53
+
54
+ return value;
55
+ },
56
+
57
+ files,
58
+
59
+ codec: createBridgeCodecs(files),
60
+
61
+ net: createBridgeNet(),
62
+
63
+ probe: createBridgeProbes(),
64
+
65
+ async command(input, options) {
66
+ return await supervisor.command(input, options);
67
+ },
68
+
69
+ exec: createBridgeExec({
70
+ cwd: config.cwd,
71
+ uid: config.gameUid,
72
+ gid: config.gameGid,
73
+ }),
74
+
75
+ logs: {
76
+ async tail(lines) {
77
+ return supervisor.tail(lines);
78
+ },
79
+
80
+ async watch(pattern, timeoutMs) {
81
+ return await supervisor.watch(pattern, timeoutMs);
82
+ },
83
+ },
84
+
85
+ emit(event, payload) {
86
+ hooks.onEmit(event, payload);
87
+ },
88
+
89
+ log(message, fields) {
90
+ hooks.onLog(message, {
91
+ driver: true,
92
+ ...fields,
93
+ });
94
+ },
95
+ };
96
+ };
@@ -0,0 +1,179 @@
1
+ import { BRIDGE_ID_PATTERN, BRIDGE_MODULE_ID_PATTERN, BridgeKind } from "../core";
2
+ import type { BridgeDriver, BridgePanelModule } from "../modules";
3
+ import { type BridgeField, BridgeFormTarget, BridgeLayout, type BridgeSection, type BridgeTab } from "../panel";
4
+
5
+ const RESERVED_MODULE_IDS = new Set([
6
+ "backup",
7
+ "events",
8
+ "install",
9
+ "lifecycle",
10
+ "query",
11
+ ]);
12
+
13
+ export class BridgeDriverError extends Error {
14
+ constructor(message: string) {
15
+ super(message);
16
+
17
+ this.name = "BridgeDriverError";
18
+ }
19
+ }
20
+
21
+ const fail = (message: string): never => {
22
+ throw new BridgeDriverError(message);
23
+ };
24
+
25
+ const validateId = (id: string, where: string) => {
26
+ if (!BRIDGE_ID_PATTERN.test(id)) {
27
+ fail(`${where} id "${id}" must be kebab-case — lowercase letters, digits, and dashes, starting with a letter`);
28
+ }
29
+ };
30
+
31
+ const validateModuleId = (id: string, where: string) => {
32
+ if (!BRIDGE_MODULE_ID_PATTERN.test(id)) {
33
+ fail(`${where} id "${id}" must be camelCase — letters and digits, starting with a lowercase letter`);
34
+ }
35
+ };
36
+
37
+ const moduleOf = (modules: Record<string, BridgePanelModule>, id: string, where: string) => {
38
+ const module = modules[id];
39
+
40
+ if (!module) {
41
+ fail(`${where} references module "${id}", which the driver does not export`);
42
+ }
43
+
44
+ return module as BridgePanelModule;
45
+ };
46
+
47
+ const validateFields = (modules: Record<string, BridgePanelModule>, fields: BridgeField[], where: string) => {
48
+ const keys = new Set<string>();
49
+
50
+ for (const field of fields) {
51
+ if (keys.has(field.key)) {
52
+ fail(`${where} declares field "${field.key}" more than once`);
53
+ }
54
+
55
+ keys.add(field.key);
56
+
57
+ if (field.options && !Array.isArray(field.options)) {
58
+ const options = moduleOf(modules, field.options.module, `${where} field "${field.key}"`);
59
+
60
+ if (options.kind !== BridgeKind.Options) {
61
+ fail(`${where} field "${field.key}" references "${field.options.module}", which is not an options module`);
62
+ }
63
+ }
64
+ }
65
+ };
66
+
67
+ const validateSection = (modules: Record<string, BridgePanelModule>, section: BridgeSection, where: string) => {
68
+ switch (section.layout) {
69
+ case BridgeLayout.Form: {
70
+ if (section.target === BridgeFormTarget.Settings && !section.module) {
71
+ fail(`${where} targets settings but names no module`);
72
+
73
+ return;
74
+ }
75
+
76
+ if (section.module) {
77
+ const module = moduleOf(modules, section.module, where);
78
+
79
+ if (module.kind !== BridgeKind.Settings) {
80
+ fail(`${where} references "${section.module}", which is not a settings module`);
81
+ } else if (section.target === BridgeFormTarget.Settings && !module.write) {
82
+ fail(`${where} targets settings but module "${section.module}" has no write handler`);
83
+ }
84
+ }
85
+
86
+ validateFields(modules, section.fields, where);
87
+
88
+ return;
89
+ }
90
+
91
+ case BridgeLayout.Table:
92
+ case BridgeLayout.Cards: {
93
+ const module = moduleOf(modules, section.module, where);
94
+
95
+ if (module.kind !== BridgeKind.Collection) {
96
+ fail(`${where} references "${section.module}", which is not a collection module`);
97
+
98
+ return;
99
+ }
100
+
101
+ if (section.layout === BridgeLayout.Table && section.add && !module.add) {
102
+ fail(`${where} declares an add control but module "${section.module}" has no add handler`);
103
+ }
104
+
105
+ for (const action of section.actions ?? []) {
106
+ if (!module.actions?.[action.id]) {
107
+ fail(`${where} declares action "${action.id}", which module "${section.module}" does not implement`);
108
+ }
109
+ }
110
+
111
+ return;
112
+ }
113
+
114
+ case BridgeLayout.Catalog: {
115
+ const module = moduleOf(modules, section.module, where);
116
+
117
+ if (module.kind !== BridgeKind.Catalog) {
118
+ fail(`${where} references "${section.module}", which is not a catalog module`);
119
+ }
120
+
121
+ return;
122
+ }
123
+ }
124
+ };
125
+
126
+ const validateTab = (modules: Record<string, BridgePanelModule>, tab: BridgeTab) => {
127
+ const sections = new Set<string>();
128
+
129
+ validateId(tab.id, "tab");
130
+
131
+ if (tab.sections.length === 0) {
132
+ fail(`tab "${tab.id}" has no sections`);
133
+ }
134
+
135
+ for (const section of tab.sections) {
136
+ if (sections.has(section.id)) {
137
+ fail(`tab "${tab.id}" declares section "${section.id}" more than once`);
138
+ }
139
+
140
+ sections.add(section.id);
141
+
142
+ validateId(section.id, `tab "${tab.id}" section`);
143
+ validateSection(modules, section, `tab "${tab.id}" section "${section.id}"`);
144
+ }
145
+ };
146
+
147
+ export const validateDriver = (driver: BridgeDriver) => {
148
+ const modules = driver.modules ?? {};
149
+
150
+ for (const [id, module] of Object.entries(modules)) {
151
+ validateModuleId(id, "module");
152
+
153
+ if (RESERVED_MODULE_IDS.has(id)) {
154
+ fail(`module id "${id}" is reserved`);
155
+ }
156
+
157
+ if (module.kind === BridgeKind.Collection) {
158
+ for (const action of Object.keys(module.actions ?? {})) {
159
+ validateModuleId(action, `module "${id}" action`);
160
+ }
161
+ }
162
+ }
163
+
164
+ if (!driver.panel) {
165
+ return;
166
+ }
167
+
168
+ const tabs = new Set<string>();
169
+
170
+ for (const tab of driver.panel.tabs) {
171
+ if (tabs.has(tab.id)) {
172
+ fail(`the panel declares tab "${tab.id}" more than once`);
173
+ }
174
+
175
+ tabs.add(tab.id);
176
+
177
+ validateTab(modules, tab);
178
+ }
179
+ };
@@ -0,0 +1,71 @@
1
+ import type { BridgeValues } from "../core";
2
+ import type { BridgeEventsModule } from "../modules";
3
+ import type { BridgeEvent } from "../protocol";
4
+ import type { BridgeSupervisor } from "./bridgeSupervisor";
5
+
6
+ const EVENT_BUFFER = 500;
7
+
8
+ export const createEventRing = (events: BridgeEventsModule | undefined, supervisor: BridgeSupervisor) => {
9
+ const ring: BridgeEvent[] = [];
10
+
11
+ let sequence = 0;
12
+
13
+ const push = (event: string, payload: BridgeValues | null) => {
14
+ sequence += 1;
15
+
16
+ ring.push({
17
+ seq: sequence,
18
+ event,
19
+ payload,
20
+ });
21
+
22
+ while (ring.length > EVENT_BUFFER) {
23
+ ring.shift();
24
+ }
25
+ };
26
+
27
+ if (events) {
28
+ supervisor.subscribe((entry) => {
29
+ for (const pattern of events.patterns) {
30
+ if (pattern.stream && pattern.stream !== entry.stream) {
31
+ continue;
32
+ }
33
+
34
+ const match = entry.line.match(pattern.match);
35
+
36
+ if (!match) {
37
+ continue;
38
+ }
39
+
40
+ const groups = match.groups
41
+ ? {
42
+ ...match.groups,
43
+ }
44
+ : null;
45
+
46
+ push(pattern.emit, groups);
47
+ }
48
+ });
49
+ }
50
+
51
+ return {
52
+ names() {
53
+ return [
54
+ ...new Set((events?.patterns ?? []).map((pattern) => pattern.emit)),
55
+ ];
56
+ },
57
+
58
+ emit(event: string, payload?: BridgeValues) {
59
+ push(event, payload ?? null);
60
+ },
61
+
62
+ drain(cursor: number) {
63
+ return {
64
+ events: ring.filter((entry) => entry.seq > cursor),
65
+ cursor: sequence,
66
+ };
67
+ },
68
+ };
69
+ };
70
+
71
+ export type BridgeEventRing = ReturnType<typeof createEventRing>;
@@ -0,0 +1,143 @@
1
+ import type { BridgeExecOptions, BridgeExecResult } from "../context";
2
+ import { BridgeStream } from "../core";
3
+ import { settleUid, unprivilegedArgv } from "./bridgePrivilege";
4
+
5
+ const DEFAULT_EXEC_TIMEOUT_MS = 900_000;
6
+
7
+ const OUTPUT_LIMIT = 256 * 1024;
8
+
9
+ const EXIT_GRACE_MS = 3000;
10
+
11
+ const BASE_ENVIRONMENT = {
12
+ HOME: "/home/container",
13
+ LANG: "C.UTF-8",
14
+ PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
15
+ };
16
+
17
+ export class BridgeExecError extends Error {
18
+ constructor(message: string) {
19
+ super(message);
20
+
21
+ this.name = "BridgeExecError";
22
+ }
23
+ }
24
+
25
+ export interface BridgeExecTarget {
26
+ cwd: string;
27
+ uid: number;
28
+ gid: number;
29
+ }
30
+
31
+ const capped = (text: string) => {
32
+ return text.length > OUTPUT_LIMIT ? text.slice(0, OUTPUT_LIMIT) : text;
33
+ };
34
+
35
+ const drain = async (
36
+ stream: ReadableStream<Uint8Array>,
37
+ report: ((line: string) => void) | undefined,
38
+ ): Promise<string> => {
39
+ const decoder = new TextDecoder();
40
+
41
+ let collected = "";
42
+ let pending = "";
43
+
44
+ const emit = (line: string) => {
45
+ const trimmed = line.trimEnd();
46
+
47
+ if (report && trimmed.trim().length > 0) {
48
+ report(trimmed);
49
+ }
50
+ };
51
+
52
+ for await (const chunk of stream) {
53
+ const text = decoder.decode(chunk, {
54
+ stream: true,
55
+ });
56
+
57
+ if (collected.length < OUTPUT_LIMIT) {
58
+ collected += text;
59
+ }
60
+
61
+ if (!report) {
62
+ continue;
63
+ }
64
+
65
+ pending += text;
66
+
67
+ const lines = pending.split("\n");
68
+
69
+ pending = lines.pop() ?? "";
70
+
71
+ for (const line of lines) {
72
+ emit(line);
73
+ }
74
+ }
75
+
76
+ emit(pending);
77
+
78
+ return collected;
79
+ };
80
+
81
+ export const createBridgeExec = (target: BridgeExecTarget) => {
82
+ return async (command: string[], options: BridgeExecOptions = {}): Promise<BridgeExecResult> => {
83
+ const [executable] = command;
84
+ const { timeoutMs, environment, onOutput } = options;
85
+
86
+ if (!executable) {
87
+ throw new BridgeExecError("the exec command is empty");
88
+ }
89
+
90
+ const { argv } = await unprivilegedArgv(command, target.uid, target.gid);
91
+
92
+ const child = Bun.spawn(argv, {
93
+ cwd: target.cwd,
94
+ env: {
95
+ ...BASE_ENVIRONMENT,
96
+ HOME: target.cwd,
97
+ ...environment,
98
+ },
99
+ stderr: "pipe",
100
+ stdin: "ignore",
101
+ stdout: "pipe",
102
+ });
103
+
104
+ const observed = await settleUid(child.pid, target.uid);
105
+
106
+ if (observed !== target.uid) {
107
+ const finished = await Promise.race([
108
+ child.exited.then(() => true),
109
+ Bun.sleep(EXIT_GRACE_MS).then(() => false),
110
+ ]);
111
+
112
+ if (observed !== null || !finished) {
113
+ child.kill("SIGKILL");
114
+
115
+ await child.exited;
116
+
117
+ const reason = observed === null ? "could not be verified" : `runs as uid ${observed}`;
118
+
119
+ throw new BridgeExecError(`the exec process ${reason}, expected uid ${target.uid} — refusing to run it`);
120
+ }
121
+ }
122
+
123
+ const timer = setTimeout(() => {
124
+ child.kill("SIGKILL");
125
+ }, timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS);
126
+
127
+ try {
128
+ const [stdout, stderr, code] = await Promise.all([
129
+ drain(child.stdout, onOutput && ((line) => onOutput(line, BridgeStream.Stdout))),
130
+ drain(child.stderr, onOutput && ((line) => onOutput(line, BridgeStream.Stderr))),
131
+ child.exited,
132
+ ]);
133
+
134
+ return {
135
+ stdout: capped(stdout),
136
+ stderr: capped(stderr),
137
+ code,
138
+ };
139
+ } finally {
140
+ clearTimeout(timer);
141
+ }
142
+ };
143
+ };