@serverkgg/bridge 0.0.1 → 0.0.3

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 (71) hide show
  1. package/package.json +10 -3
  2. package/src/cli/link.test.ts +199 -0
  3. package/src/cli/link.ts +319 -0
  4. package/src/cli/main.ts +19 -1
  5. package/src/cli/schema.ts +62 -0
  6. package/src/cli/validate.test.ts +89 -0
  7. package/src/cli/validate.ts +218 -35
  8. package/src/context/bridgeContext.ts +18 -2
  9. package/src/core/bridgeAction.ts +13 -0
  10. package/src/core/bridgeFailure.ts +16 -0
  11. package/src/core/bridgeKind.ts +3 -0
  12. package/src/core/bridgeNetError.ts +10 -0
  13. package/src/core/index.ts +2 -0
  14. package/src/guides/blocks.ts +167 -0
  15. package/src/guides/compile.ts +193 -0
  16. package/src/guides/compiled.ts +28 -0
  17. package/src/guides/index.ts +6 -0
  18. package/src/guides/markdown.test.ts +374 -0
  19. package/src/guides/markdown.ts +769 -0
  20. package/src/guides/variables.ts +52 -0
  21. package/src/guides/version.ts +1 -0
  22. package/src/main.ts +2 -1
  23. package/src/manifest/assets.ts +4 -4
  24. package/src/manifest/guide.ts +23 -18
  25. package/src/manifest/index.ts +3 -2
  26. package/src/manifest/jsonSchema.ts +16 -0
  27. package/src/manifest/localized.ts +7 -0
  28. package/src/manifest/manifest.test.ts +366 -0
  29. package/src/manifest/manifest.ts +354 -120
  30. package/src/manifest/primitives.ts +63 -0
  31. package/src/manifest/source.ts +5 -4
  32. package/src/modules/bridge.ts +25 -4
  33. package/src/modules/bridgeDriver.ts +4 -0
  34. package/src/modules/bridgeModule.ts +36 -24
  35. package/src/panel/bridgeAccess.ts +3 -0
  36. package/src/panel/bridgeLayout.ts +1 -0
  37. package/src/panel/bridgePanel.ts +40 -2
  38. package/src/panel/index.ts +1 -0
  39. package/src/protocol/bridgeCatalog.ts +57 -0
  40. package/src/protocol/bridgeEgress.ts +53 -0
  41. package/src/protocol/bridgeEvents.ts +20 -0
  42. package/src/protocol/bridgeFrame.ts +16 -0
  43. package/src/protocol/bridgeLimits.ts +41 -0
  44. package/src/protocol/bridgeLine.ts +41 -6
  45. package/src/protocol/bridgeManifest.ts +15 -1
  46. package/src/protocol/bridgeVersion.ts +1 -1
  47. package/src/protocol/index.ts +5 -0
  48. package/src/runtime/bridgeActions.ts +53 -0
  49. package/src/runtime/bridgeContext.ts +29 -9
  50. package/src/runtime/bridgeDriver.test.ts +450 -0
  51. package/src/runtime/bridgeDriver.ts +246 -6
  52. package/src/runtime/bridgeEgress.ts +360 -0
  53. package/src/runtime/bridgeEvents.ts +4 -1
  54. package/src/runtime/bridgeExec.ts +15 -5
  55. package/src/runtime/bridgeFiles.test.ts +334 -0
  56. package/src/runtime/bridgeFiles.ts +130 -31
  57. package/src/runtime/bridgeLog.ts +26 -3
  58. package/src/runtime/bridgeNet.ts +75 -22
  59. package/src/runtime/bridgeRegistry.test.ts +291 -0
  60. package/src/runtime/bridgeRegistry.ts +91 -52
  61. package/src/runtime/bridgeServer.ts +35 -3
  62. package/src/runtime/bridgeSupervisor.ts +6 -6
  63. package/src/runtime/bridgeZip.ts +14 -3
  64. package/src/runtime/index.ts +0 -17
  65. package/src/runtime/serve.ts +44 -3
  66. package/src/terminal/bridgeTerminalArg.ts +11 -0
  67. package/src/terminal/bridgeTerminalCommand.ts +11 -0
  68. package/src/terminal/bridgeTerminalLevel.ts +6 -0
  69. package/src/terminal/bridgeTerminalRule.ts +8 -0
  70. package/src/terminal/index.ts +4 -0
  71. package/src/manifest/checks.ts +0 -50
@@ -1,50 +1,103 @@
1
1
  import type { BridgeNet, BridgeRequestOptions } from "../context";
2
+ import { BridgeNetError } from "../core";
3
+ import { type BridgeEgress, BridgeEgressCallError, BridgeEgressUnavailableError } from "./bridgeEgress";
2
4
 
3
5
  const REQUEST_TIMEOUT_MS = 15_000;
4
6
 
5
7
  const RESPONSE_LIMIT = 8 * 1024 * 1024;
6
8
 
7
- const USER_AGENT = "serverk-bridge/1.0 (serverk.gg)";
9
+ export const USER_AGENT = "serverk-bridge/1.0 (serverk.gg)";
8
10
 
9
- export class BridgeNetError extends Error {
10
- readonly status: number | null;
11
+ const LINE_BREAK = /[\r\n]/;
11
12
 
12
- constructor(status: number | null, message: string) {
13
- super(message);
13
+ export const safeHeaders = (headers: Record<string, string> = {}) => {
14
+ const checked: Record<string, string> = {};
14
15
 
15
- this.name = "BridgeNetError";
16
- this.status = status;
16
+ for (const [key, value] of Object.entries(headers)) {
17
+ if (LINE_BREAK.test(key) || LINE_BREAK.test(value)) {
18
+ throw new BridgeNetError(null, "a header contains a line break");
19
+ }
20
+
21
+ checked[key] = value;
17
22
  }
18
- }
19
23
 
20
- export const createBridgeNet = (): BridgeNet => {
21
- const request = async (url: string, options: BridgeRequestOptions = {}) => {
22
- const remote = new URL(url);
24
+ return checked;
25
+ };
23
26
 
24
- if (remote.protocol !== "https:") {
25
- throw new BridgeNetError(null, `"${url}" is not https`);
27
+ const readCapped = async (response: Response, host: string) => {
28
+ if (!response.body) {
29
+ return "";
30
+ }
31
+
32
+ const decoder = new TextDecoder();
33
+ const parts: string[] = [];
34
+
35
+ let read = 0;
36
+
37
+ for await (const chunk of response.body) {
38
+ read += chunk.byteLength;
39
+
40
+ if (read > RESPONSE_LIMIT) {
41
+ throw new BridgeNetError(null, `${host} returned more than ${RESPONSE_LIMIT} bytes`);
26
42
  }
27
43
 
28
- const response = await fetch(url, {
44
+ parts.push(
45
+ decoder.decode(chunk, {
46
+ stream: true,
47
+ }),
48
+ );
49
+ }
50
+
51
+ parts.push(decoder.decode());
52
+
53
+ return parts.join("");
54
+ };
55
+
56
+ export const createBridgeNet = (egress: BridgeEgress): BridgeNet => {
57
+ const direct = async (remote: URL, headers: Record<string, string>, timeoutMs: number) => {
58
+ const response = await fetch(remote, {
29
59
  headers: {
60
+ ...headers,
30
61
  "user-agent": USER_AGENT,
31
- ...options.headers,
32
62
  },
33
- redirect: "follow",
34
- signal: AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS),
63
+ redirect: "error",
64
+ signal: AbortSignal.timeout(timeoutMs),
35
65
  });
36
66
 
37
67
  if (!response.ok) {
38
- throw new BridgeNetError(response.status, `${url} answered ${response.status}`);
68
+ throw new BridgeNetError(response.status, `${remote.host} answered ${response.status}`);
39
69
  }
40
70
 
41
- const body = await response.text();
71
+ return await readCapped(response, remote.host);
72
+ };
73
+
74
+ const request = async (url: string, options: BridgeRequestOptions = {}) => {
75
+ const remote = new URL(url);
76
+
77
+ if (remote.protocol !== "https:") {
78
+ throw new BridgeNetError(null, `${remote.host} is not https`);
79
+ }
80
+
81
+ const headers = safeHeaders(options.headers);
82
+
83
+ try {
84
+ const result = await egress.fetch(url, {
85
+ ...options,
86
+ headers,
87
+ });
88
+
89
+ return result.body;
90
+ } catch (error) {
91
+ if (error instanceof BridgeEgressCallError) {
92
+ throw new BridgeNetError(error.status, error.message);
93
+ }
42
94
 
43
- if (body.length > RESPONSE_LIMIT) {
44
- throw new BridgeNetError(null, `${url} returned more than ${RESPONSE_LIMIT} bytes`);
95
+ if (!(error instanceof BridgeEgressUnavailableError)) {
96
+ throw error;
97
+ }
45
98
  }
46
99
 
47
- return body;
100
+ return await direct(remote, headers, options.timeoutMs ?? REQUEST_TIMEOUT_MS);
48
101
  };
49
102
 
50
103
  return {
@@ -0,0 +1,291 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { tmpdir } from "node:os";
3
+ import { BridgeAction, BridgeKind, type BridgeRow, type BridgeValues } from "../core";
4
+ import type { BridgeDriver } from "../modules";
5
+ import { type BridgeConfig, BridgeEventName } from "../protocol";
6
+ import { createContext } from "./bridgeContext";
7
+ import { createEventRing } from "./bridgeEvents";
8
+ import { log } from "./bridgeLog";
9
+ import { createRegistry } from "./bridgeRegistry";
10
+ import { createSupervisor } from "./bridgeSupervisor";
11
+
12
+ const config = (): BridgeConfig => ({
13
+ code: "abcdef",
14
+ token: "token",
15
+ cwd: `${tmpdir()}/serverk-registry-test`,
16
+ run: `${tmpdir()}/serverk-registry-test/run`,
17
+ ports: {},
18
+ memoryMb: 1024,
19
+ cpuPercent: 100,
20
+ gameUid: 1000,
21
+ gameGid: 1000,
22
+ variables: {},
23
+ secrets: {},
24
+ });
25
+
26
+ const baseDriver = (): BridgeDriver => ({
27
+ install: {
28
+ kind: BridgeKind.Install,
29
+ run: async () => {
30
+ return;
31
+ },
32
+ },
33
+ lifecycle: {
34
+ kind: BridgeKind.Lifecycle,
35
+ command: async () => [
36
+ "run",
37
+ ],
38
+ },
39
+ });
40
+
41
+ const registryFor = (driver: BridgeDriver) => {
42
+ const settings = config();
43
+
44
+ const supervisor = createSupervisor({
45
+ cwd: settings.cwd,
46
+ uid: settings.gameUid,
47
+ gid: settings.gameGid,
48
+ stopTimeoutSeconds: 1,
49
+ onLog: log,
50
+ resolveCommand: async () => [
51
+ "run",
52
+ ],
53
+ });
54
+
55
+ const ring = createEventRing(driver.events, supervisor);
56
+
57
+ const context = createContext(settings, supervisor, {
58
+ onLog: log,
59
+ onEmit(event, payload) {
60
+ ring.emit(event, payload);
61
+ },
62
+ });
63
+
64
+ return createRegistry(driver, context, supervisor, ring);
65
+ };
66
+
67
+ describe("actions modules", () => {
68
+ test("lists its action ids in the manifest", () => {
69
+ const registry = registryFor({
70
+ ...baseDriver(),
71
+ modules: {
72
+ gameplay: {
73
+ kind: BridgeKind.Actions,
74
+ requiresRunning: true,
75
+ actions: {
76
+ timeDay: async () => {
77
+ return;
78
+ },
79
+ weatherClear: async () => {
80
+ return;
81
+ },
82
+ },
83
+ },
84
+ },
85
+ });
86
+
87
+ expect(registry.manifest().modules).toEqual([
88
+ {
89
+ id: "gameplay",
90
+ kind: BridgeKind.Actions,
91
+ requiresRunning: true,
92
+ refreshSeconds: null,
93
+ pageSize: null,
94
+ dependsOn: [],
95
+ actions: [
96
+ "timeDay",
97
+ "weatherClear",
98
+ ],
99
+ },
100
+ ]);
101
+ });
102
+
103
+ test("hands the whole argument bag to the handler", async () => {
104
+ const received: BridgeValues[] = [];
105
+
106
+ const registry = registryFor({
107
+ ...baseDriver(),
108
+ modules: {
109
+ broadcast: {
110
+ kind: BridgeKind.Actions,
111
+ actions: {
112
+ say: async (_context, args) => {
113
+ received.push(args);
114
+ },
115
+ },
116
+ },
117
+ },
118
+ });
119
+
120
+ await registry.invoke("broadcast", "say", {
121
+ message: "hello",
122
+ });
123
+
124
+ expect(received).toEqual([
125
+ {
126
+ message: "hello",
127
+ },
128
+ ]);
129
+ });
130
+
131
+ test("refuses to run while the game is stopped", async () => {
132
+ const registry = registryFor({
133
+ ...baseDriver(),
134
+ modules: {
135
+ broadcast: {
136
+ kind: BridgeKind.Actions,
137
+ requiresRunning: true,
138
+ actions: {
139
+ say: async () => {
140
+ return;
141
+ },
142
+ },
143
+ },
144
+ },
145
+ });
146
+
147
+ await expect(
148
+ registry.invoke("broadcast", "say", {
149
+ message: "hello",
150
+ }),
151
+ ).rejects.toThrow('module "broadcast" needs the game to be running');
152
+ });
153
+
154
+ test("rejects a driver that reuses a read action id", () => {
155
+ expect(() =>
156
+ registryFor({
157
+ ...baseDriver(),
158
+ modules: {
159
+ broadcast: {
160
+ kind: BridgeKind.Actions,
161
+ actions: {
162
+ list: async () => {
163
+ return;
164
+ },
165
+ },
166
+ },
167
+ },
168
+ }),
169
+ ).toThrow('module "broadcast" declares action "list", which is reserved for reads');
170
+ });
171
+ });
172
+
173
+ describe("manifest events", () => {
174
+ test("lists the log patterns and the names the driver raises itself", () => {
175
+ const registry = registryFor({
176
+ ...baseDriver(),
177
+ events: {
178
+ kind: BridgeKind.Events,
179
+ patterns: [
180
+ {
181
+ match: /Fatal error/,
182
+ emit: BridgeEventName.ServerCrashed,
183
+ },
184
+ {
185
+ match: /Fatal error again/,
186
+ emit: BridgeEventName.ServerCrashed,
187
+ },
188
+ ],
189
+ emits: [
190
+ BridgeEventName.PlayerJoined,
191
+ BridgeEventName.PlayerLeft,
192
+ BridgeEventName.ServerCrashed,
193
+ ],
194
+ },
195
+ });
196
+
197
+ expect(registry.manifest().events).toEqual([
198
+ BridgeEventName.ServerCrashed,
199
+ BridgeEventName.PlayerJoined,
200
+ BridgeEventName.PlayerLeft,
201
+ ]);
202
+ });
203
+
204
+ test("stays on the log patterns when the driver raises nothing itself", () => {
205
+ const registry = registryFor({
206
+ ...baseDriver(),
207
+ events: {
208
+ kind: BridgeKind.Events,
209
+ patterns: [
210
+ {
211
+ match: /Done/,
212
+ emit: BridgeEventName.ServerStarted,
213
+ },
214
+ ],
215
+ },
216
+ });
217
+
218
+ expect(registry.manifest().events).toEqual([
219
+ BridgeEventName.ServerStarted,
220
+ ]);
221
+ });
222
+ });
223
+
224
+ describe("collection row actions", () => {
225
+ test("passes the row and the arguments without the row id", async () => {
226
+ const seen: {
227
+ row: BridgeRow;
228
+ args: BridgeValues;
229
+ }[] = [];
230
+
231
+ const registry = registryFor({
232
+ ...baseDriver(),
233
+ modules: {
234
+ players: {
235
+ kind: BridgeKind.Collection,
236
+ list: async () => [
237
+ {
238
+ id: "meslzy",
239
+ },
240
+ ],
241
+ actions: {
242
+ xp: async (_context, row, args) => {
243
+ seen.push({
244
+ row,
245
+ args,
246
+ });
247
+ },
248
+ },
249
+ },
250
+ },
251
+ });
252
+
253
+ await registry.invoke("players", "xp", {
254
+ id: "meslzy",
255
+ amount: 30,
256
+ });
257
+
258
+ expect(seen).toEqual([
259
+ {
260
+ row: {
261
+ id: "meslzy",
262
+ },
263
+ args: {
264
+ amount: 30,
265
+ },
266
+ },
267
+ ]);
268
+ });
269
+
270
+ test("keeps listing the built-in actions beside the row actions", () => {
271
+ const registry = registryFor({
272
+ ...baseDriver(),
273
+ modules: {
274
+ players: {
275
+ kind: BridgeKind.Collection,
276
+ list: async () => [],
277
+ actions: {
278
+ kick: async () => {
279
+ return;
280
+ },
281
+ },
282
+ },
283
+ },
284
+ });
285
+
286
+ expect(registry.manifest().modules[0]?.actions).toEqual([
287
+ BridgeAction.List,
288
+ "kick",
289
+ ]);
290
+ });
291
+ });
@@ -1,7 +1,13 @@
1
1
  import type { BridgeContext } from "../context";
2
- import { BridgeAction, BridgeKind, type BridgeRow, type BridgeValues } from "../core";
3
- import type { BridgeDriver, BridgePanelModule } from "../modules";
4
- import { BRIDGE_VERSION, type BridgeManifest, type BridgeModuleManifest } from "../protocol";
2
+ import { BridgeAction, BridgeKind, type BridgeRow, type BridgeValues, isReadAction } from "../core";
3
+ import type { BridgeDriver, BridgePanelModule, BridgeTerminalModule } from "../modules";
4
+ import {
5
+ BRIDGE_VERSION,
6
+ type BridgeManifest,
7
+ type BridgeModuleManifest,
8
+ type BridgeTerminalManifest,
9
+ } from "../protocol";
10
+ import { actionsOf } from "./bridgeActions";
5
11
  import { BridgeDriverError } from "./bridgeDriver";
6
12
  import type { BridgeEventRing } from "./bridgeEvents";
7
13
  import type { BridgeSupervisor } from "./bridgeSupervisor";
@@ -11,50 +17,12 @@ interface CacheEntry {
11
17
  expiresAt: number;
12
18
  }
13
19
 
14
- const actionsOf = (module: BridgePanelModule) => {
15
- switch (module.kind) {
16
- case BridgeKind.Options: {
17
- return [
18
- BridgeAction.List,
19
- ];
20
- }
21
-
22
- case BridgeKind.Settings: {
23
- return [
24
- BridgeAction.Read,
25
- ...(module.write
26
- ? [
27
- BridgeAction.Write,
28
- ]
29
- : []),
30
- ];
31
- }
32
-
33
- case BridgeKind.Collection: {
34
- return [
35
- BridgeAction.List,
36
- ...(module.add
37
- ? [
38
- BridgeAction.Add,
39
- ]
40
- : []),
41
- ...Object.keys(module.actions ?? {}),
42
- ];
43
- }
44
-
45
- case BridgeKind.Catalog: {
46
- return [
47
- BridgeAction.Install,
48
- BridgeAction.Installed,
49
- BridgeAction.Remove,
50
- BridgeAction.Search,
51
- ];
52
- }
20
+ const requiresRunning = (module: BridgePanelModule) => {
21
+ if (module.kind === BridgeKind.Actions || module.kind === BridgeKind.Collection) {
22
+ return module.requiresRunning ?? false;
53
23
  }
54
- };
55
24
 
56
- const requiresRunning = (module: BridgePanelModule) => {
57
- return module.kind === BridgeKind.Collection ? (module.requiresRunning ?? false) : false;
25
+ return false;
58
26
  };
59
27
 
60
28
  const refreshOf = (module: BridgePanelModule) => {
@@ -69,6 +37,24 @@ const pageSizeOf = (module: BridgePanelModule) => {
69
37
  return module.kind === BridgeKind.Catalog ? (module.pageSize ?? null) : null;
70
38
  };
71
39
 
40
+ const terminalManifestOf = (module: BridgeTerminalModule | undefined): BridgeTerminalManifest | null => {
41
+ if (!module) {
42
+ return null;
43
+ }
44
+
45
+ return {
46
+ commands: module.commands,
47
+ rules: (module.rules ?? []).map((rule) => {
48
+ return {
49
+ match: rule.match.source,
50
+ flags: rule.match.flags,
51
+ level: rule.level,
52
+ stream: rule.stream,
53
+ };
54
+ }),
55
+ };
56
+ };
57
+
72
58
  const withVariables = (context: BridgeContext, overlay: Record<string, string>): BridgeContext => {
73
59
  return {
74
60
  get server() {
@@ -97,9 +83,7 @@ const withVariables = (context: BridgeContext, overlay: Record<string, string>):
97
83
  emit(event, payload) {
98
84
  context.emit(event, payload);
99
85
  },
100
- log(message, fields) {
101
- context.log(message, fields);
102
- },
86
+ log: context.log,
103
87
  };
104
88
  };
105
89
 
@@ -119,6 +103,24 @@ const asText = (args: BridgeValues, key: string) => {
119
103
  return typeof value === "string" ? value : "";
120
104
  };
121
105
 
106
+ const asOptional = (args: BridgeValues, key: string) => {
107
+ const value = args[key];
108
+
109
+ return typeof value === "string" && value.length > 0 ? value : null;
110
+ };
111
+
112
+ const withoutId = (args: BridgeValues) => {
113
+ const rest: BridgeValues = {};
114
+
115
+ for (const [key, value] of Object.entries(args)) {
116
+ if (key !== "id") {
117
+ rest[key] = value;
118
+ }
119
+ }
120
+
121
+ return rest;
122
+ };
123
+
122
124
  const asPage = (args: BridgeValues) => {
123
125
  const value = args.page;
124
126
 
@@ -135,6 +137,20 @@ export const createRegistry = (
135
137
  const inflight = new Map<string, Promise<unknown>>();
136
138
  const modules = driver.modules ?? {};
137
139
 
140
+ for (const [id, module] of Object.entries(modules)) {
141
+ if (module.kind !== BridgeKind.Actions) {
142
+ continue;
143
+ }
144
+
145
+ for (const action of Object.keys(module.actions)) {
146
+ if (isReadAction(action)) {
147
+ throw new BridgeDriverError(
148
+ `module "${id}" declares action "${action}", which is reserved for reads — pick another id`,
149
+ );
150
+ }
151
+ }
152
+ }
153
+
138
154
  const cached = async (key: string, ttlSeconds: number | undefined, run: () => Promise<unknown>) => {
139
155
  if (!ttlSeconds) {
140
156
  return await run();
@@ -175,6 +191,18 @@ export const createRegistry = (
175
191
 
176
192
  const dispatch = async (module: BridgePanelModule, name: string, action: string, args: BridgeValues) => {
177
193
  switch (module.kind) {
194
+ case BridgeKind.Actions: {
195
+ const serverAction = module.actions[action];
196
+
197
+ if (serverAction) {
198
+ await serverAction(context, args);
199
+
200
+ return null;
201
+ }
202
+
203
+ break;
204
+ }
205
+
178
206
  case BridgeKind.Options: {
179
207
  if (action === BridgeAction.List) {
180
208
  const overlay: Record<string, string> = {};
@@ -236,7 +264,7 @@ export const createRegistry = (
236
264
  throw new BridgeDriverError(`module "${name}" has no row "${id}"`);
237
265
  }
238
266
 
239
- await rowAction(context, row as BridgeRow);
267
+ await rowAction(context, row as BridgeRow, withoutId(args));
240
268
 
241
269
  return null;
242
270
  }
@@ -246,7 +274,13 @@ export const createRegistry = (
246
274
 
247
275
  case BridgeKind.Catalog: {
248
276
  if (action === BridgeAction.Search) {
249
- return await module.search(context, asText(args, "query"), asPage(args));
277
+ return await module.search(context, {
278
+ query: asText(args, "query"),
279
+ page: asPage(args),
280
+ provider: asOptional(args, "provider"),
281
+ category: asOptional(args, "category"),
282
+ sort: asOptional(args, "sort"),
283
+ });
250
284
  }
251
285
 
252
286
  if (action === BridgeAction.Installed) {
@@ -263,6 +297,12 @@ export const createRegistry = (
263
297
  return null;
264
298
  }
265
299
 
300
+ if (action === BridgeAction.Toggle && module.toggle) {
301
+ await module.toggle(context, asString(args, "id"), args.enabled !== false);
302
+
303
+ return null;
304
+ }
305
+
266
306
  break;
267
307
  }
268
308
  }
@@ -287,6 +327,7 @@ export const createRegistry = (
287
327
  return {
288
328
  version: BRIDGE_VERSION,
289
329
  panel: driver.panel ?? null,
330
+ terminal: terminalManifestOf(driver.terminal),
290
331
  modules: entries,
291
332
  events: ring.names(),
292
333
  };
@@ -307,5 +348,3 @@ export const createRegistry = (
307
348
  },
308
349
  };
309
350
  };
310
-
311
- export type BridgeRegistry = ReturnType<typeof createRegistry>;
@@ -1,8 +1,10 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
- import { BridgeSecretError, type BridgeValues } from "../core";
2
+ import { BridgeFailureError, BridgeSecretError, type BridgeValues } from "../core";
3
3
  import {
4
4
  BRIDGE_FRAME_LIMIT,
5
+ type BridgeAnnounceResult,
5
6
  type BridgeCommandResult,
7
+ type BridgeDescribeResult,
6
8
  type BridgeEventsResult,
7
9
  type BridgeManifest,
8
10
  BridgeMethod,
@@ -26,6 +28,8 @@ export interface ServerOptions {
26
28
  onSample: () => Promise<BridgeSampleResult>;
27
29
  onQuiesce: () => Promise<BridgeQuiesceResult>;
28
30
  onRelease: () => Promise<void>;
31
+ onDescribe: () => Promise<BridgeDescribeResult>;
32
+ onAnnounce: (message: string) => Promise<BridgeAnnounceResult>;
29
33
  onLog: BridgeLogger;
30
34
  }
31
35
 
@@ -40,8 +44,13 @@ interface IncomingFrame {
40
44
  action?: unknown;
41
45
  args?: unknown;
42
46
  cursor?: unknown;
47
+ message?: unknown;
43
48
  }
44
49
 
50
+ const asMessage = (value: unknown): string | null => {
51
+ return typeof value === "string" && value.length > 0 ? value : null;
52
+ };
53
+
45
54
  const matches = (left: string, right: string) => {
46
55
  const a = Buffer.from(left);
47
56
  const b = Buffer.from(right);
@@ -122,6 +131,20 @@ export const createServer = (options: ServerOptions) => {
122
131
  return null;
123
132
  }
124
133
 
134
+ case BridgeMethod.Describe: {
135
+ return await options.onDescribe();
136
+ }
137
+
138
+ case BridgeMethod.Announce: {
139
+ const message = asMessage(request.message);
140
+
141
+ if (!message) {
142
+ throw new Error("announce needs a message");
143
+ }
144
+
145
+ return await options.onAnnounce(message);
146
+ }
147
+
125
148
  default: {
126
149
  throw new Error(`unknown method "${method}"`);
127
150
  }
@@ -205,6 +228,12 @@ export const createServer = (options: ServerOptions) => {
205
228
  );
206
229
  })
207
230
  .catch((error: unknown) => {
231
+ options.onLog("bridge call failed", {
232
+ method: String(request.method ?? ""),
233
+ error: errorText(error),
234
+ stack: error instanceof Error ? (error.stack ?? null) : null,
235
+ });
236
+
208
237
  socket.write(
209
238
  encode({
210
239
  id,
@@ -215,6 +244,11 @@ export const createServer = (options: ServerOptions) => {
215
244
  secret: error.key,
216
245
  }
217
246
  : {}),
247
+ ...(error instanceof BridgeFailureError
248
+ ? {
249
+ failure: error.code,
250
+ }
251
+ : {}),
218
252
  }),
219
253
  );
220
254
  });
@@ -229,5 +263,3 @@ export const createServer = (options: ServerOptions) => {
229
263
  },
230
264
  });
231
265
  };
232
-
233
- export type BridgeSocketServer = ReturnType<typeof createServer>;