@rainbow-robotics/plugin-ui 1.3.8-dev.4 → 1.10.1-dev.0

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/src/ws.ts CHANGED
@@ -1,256 +1,256 @@
1
- /**
2
- * Socket.IO communication client for plugin frontends.
3
- *
4
- * Topics are automatically namespaced to prevent cross-plugin collisions:
5
- * "sensor/data" → "{pluginId}/sensor/data"
6
- * "/broadcast/x" → "broadcast/x" (leading slash = absolute)
7
- *
8
- * Browser ←→ Python backend protocol:
9
- * browser emits : sub, unsub, pub, action (with ack), serve_reply
10
- * server emits : msg, serve_call
11
- */
12
- import { io, type Socket } from "socket.io-client";
13
- // Type-only — no runtime coupling to host-ui.ts.
14
- import type { PluginUIServices } from "./host-ui";
15
-
16
- export interface WsHandle {
17
- undeclare(): void;
18
- }
19
-
20
- export type WsSubscriber = WsHandle;
21
- export type WsQueryable = WsHandle;
22
-
23
- function resolveTopic(pluginId: string, topic: string): string {
24
- return topic.startsWith("/") ? topic.slice(1) : `${pluginId}/${topic}`;
25
- }
26
-
27
- export class RbWs {
28
- private readonly _socket: Socket;
29
- private readonly _pluginId: string;
30
- private readonly _subHandlers = new Map<
31
- string,
32
- Set<(payload: unknown) => void>
33
- >();
34
- private readonly _serveHandlers = new Map<
35
- string,
36
- (payload?: unknown) => unknown | Promise<unknown>
37
- >();
38
-
39
- private constructor(socket: Socket, pluginId: string) {
40
- this._socket = socket;
41
- this._pluginId = pluginId;
42
-
43
- // The gateway keeps subscriptions/serve registrations in memory — re-send them after
44
- // a reconnect (e.g. the gateway restarted), or they silently stop working.
45
- socket.io.on("reconnect", () => {
46
- for (const topic of this._subHandlers.keys()) socket.emit("sub", { topic });
47
- for (const topic of this._serveHandlers.keys()) socket.emit("serve_register", { topic });
48
- });
49
-
50
- // Deliver incoming pub/sub messages to registered handlers
51
- socket.on(
52
- "msg",
53
- ({ topic, payload }: { topic: string; payload: unknown }) => {
54
- this._subHandlers.get(topic)?.forEach((h) => h(payload));
55
- },
56
- );
57
-
58
- // Python calls a browser-side serve handler
59
- socket.on(
60
- "serve_call",
61
- async ({
62
- id,
63
- topic,
64
- payload,
65
- }: {
66
- id: string;
67
- topic: string;
68
- payload: unknown;
69
- }) => {
70
- const handler = this._serveHandlers.get(topic);
71
- const result = handler ? await handler(payload) : undefined;
72
- socket.emit("serve_reply", { id, payload: result });
73
- },
74
- );
75
- }
76
-
77
- get pluginId(): string {
78
- return this._pluginId;
79
- }
80
-
81
- static connect(url: string, pluginId: string): Promise<RbWs> {
82
- return new Promise((resolve, reject) => {
83
- const socket = io(url, { transports: ["websocket"] });
84
- const onConnect = () => {
85
- cleanup();
86
- resolve(new RbWs(socket, pluginId));
87
- };
88
- const onError = (err: Error) => {
89
- cleanup();
90
- reject(err);
91
- };
92
- const cleanup = () => {
93
- socket.off("connect", onConnect);
94
- socket.off("connect_error", onError);
95
- };
96
- socket.once("connect", onConnect);
97
- socket.once("connect_error", onError);
98
- });
99
- }
100
-
101
- private _key(topic: string): string {
102
- return resolveTopic(this._pluginId, topic);
103
- }
104
-
105
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
106
- async pub<P = any>(topic: string, payload: P): Promise<void> {
107
- this._socket.emit("pub", { topic: this._key(topic), payload });
108
- }
109
-
110
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
111
- async sub<P = any>(
112
- topic: string,
113
- handler: (payload: P) => void,
114
- ): Promise<WsSubscriber> {
115
- const key = this._key(topic);
116
- if (!this._subHandlers.has(key)) {
117
- this._subHandlers.set(key, new Set());
118
- this._socket.emit("sub", { topic: key });
119
- }
120
- const h = handler as (payload: unknown) => void;
121
- this._subHandlers.get(key)!.add(h);
122
- return {
123
- undeclare: () => {
124
- const set = this._subHandlers.get(key);
125
- if (!set) return;
126
- set.delete(h);
127
- if (set.size === 0) {
128
- this._subHandlers.delete(key);
129
- this._socket.emit("unsub", { topic: key });
130
- }
131
- },
132
- };
133
- }
134
-
135
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
136
- async action<R = any, P = any>(topic: string, payload?: P): Promise<R> {
137
- const key = this._key(topic);
138
- return new Promise((resolve, reject) => {
139
- this._socket
140
- .timeout(10_000)
141
- .emit(
142
- "action",
143
- { topic: key, payload },
144
- (err: Error | null, result: R) => {
145
- if (err) {
146
- // socket.io ack timeout — the bridge itself never answered.
147
- reject(new Error(`action "${key}": no ack from bridge (${err.message})`));
148
- } else if (
149
- result &&
150
- typeof result === "object" &&
151
- "error" in result &&
152
- typeof result.error === "string"
153
- ) {
154
- // Backend handler returned an error payload — surface it verbatim.
155
- reject(new Error(result.error));
156
- } else if (result === undefined || result === null) {
157
- // The bridge acked but with no payload: the backend handler is
158
- // registered yet didn't reply in time (hung inside the handler,
159
- // e.g. a blocked SDK call) or returned nothing. Distinct from a
160
- // "No handler registered" error, which comes back as result.error.
161
- reject(
162
- new Error(
163
- `action "${key}": no reply from plugin backend — the handler was reached but didn't return in time (check the backend's stdout on the robot).`,
164
- ),
165
- );
166
- } else {
167
- resolve(result);
168
- }
169
- },
170
- );
171
- });
172
- }
173
-
174
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
175
- async serve<P = any, R = any>(
176
- topic: string,
177
- handler: (payload?: P) => R | Promise<R>,
178
- ): Promise<WsQueryable> {
179
- const key = this._key(topic);
180
- this._serveHandlers.set(key, handler as (payload?: unknown) => unknown);
181
- this._socket.emit("serve_register", { topic: key });
182
- return {
183
- undeclare: () => {
184
- this._serveHandlers.delete(key);
185
- this._socket.emit("serve_unregister", { topic: key });
186
- },
187
- };
188
- }
189
-
190
- close(): void {
191
- this._socket.disconnect();
192
- }
193
- }
194
-
195
- /**
196
- * Builds an rb-sdk-shaped object (pub/sub/action/serve + the passed-through
197
- * toast/alert/confirm/prompt) that lazily awaits a not-yet-connected `RbWs` —
198
- * so callers don't block their first render on the WebSocket handshake (there
199
- * may be no backend reachable yet). Shared by both host mount paths (see
200
- * ./host.tsx and muscat-plugin-kit's dev bootstrap), which otherwise
201
- * duplicate this wiring.
202
- */
203
- export function createLazyRbSdk(
204
- wsPromise: Promise<RbWs>,
205
- services: PluginUIServices,
206
- // Per-mount, host-provided (unlike `services`, which is a global singleton):
207
- // initial `data` and the `onSave` registration the host reads back. Plain
208
- // values/closures, so they cross the dev React-instance boundary safely.
209
- mount?: {
210
- data?: unknown;
211
- onSave?: (getPayload: () => unknown) => () => void;
212
- },
213
- ) {
214
- return {
215
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
216
- pub: async (topic: string, payload: any) =>
217
- (await wsPromise).pub(topic, payload),
218
- sub: async (
219
- topic: string,
220
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
221
- handler: (payload: any) => void,
222
- ) => (await wsPromise).sub(topic, handler),
223
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
224
- action: async (topic: string, payload?: any) =>
225
- (await wsPromise).action(topic, payload),
226
- serve: async (
227
- topic: string,
228
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
229
- handler: (payload?: any) => any,
230
- ) => (await wsPromise).serve(topic, handler),
231
- toast: services.toast,
232
- alert: services.alert,
233
- confirm: services.confirm,
234
- prompt: services.prompt,
235
- program: services.program,
236
- data: mount?.data,
237
- onSave: mount?.onSave ?? (() => () => {}),
238
- rosPeers: rosPeersApi(async (op, payload) => {
239
- const ws = await wsPromise;
240
- return ws.action(`/muscat/plugin/${ws.pluginId}/ros_peers/${op}`, payload);
241
- }),
242
- };
243
- }
244
-
245
- // Answered by Muscat (common) on the robot and by `muscat-pk dev` locally.
246
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
247
- function rosPeersApi(call: (op: "get" | "set", payload?: any) => Promise<any>) {
248
- return {
249
- get: async (): Promise<string[]> => (await call("get"))?.peers ?? [],
250
- set: async (peers: string[]): Promise<string[]> => {
251
- const r = await call("set", { peers });
252
- if (!r?.ok) throw new Error(r?.error ?? "rb.rosPeers.set failed");
253
- return r.peers;
254
- },
255
- };
256
- }
1
+ /**
2
+ * Socket.IO communication client for plugin frontends.
3
+ *
4
+ * Topics are automatically namespaced to prevent cross-plugin collisions:
5
+ * "sensor/data" → "{pluginId}/sensor/data"
6
+ * "/broadcast/x" → "broadcast/x" (leading slash = absolute)
7
+ *
8
+ * Browser ←→ Python backend protocol:
9
+ * browser emits : sub, unsub, pub, action (with ack), serve_reply
10
+ * server emits : msg, serve_call
11
+ */
12
+ import { io, type Socket } from "socket.io-client";
13
+ // Type-only — no runtime coupling to host-ui.ts.
14
+ import type { PluginUIServices } from "./host-ui";
15
+
16
+ export interface WsHandle {
17
+ undeclare(): void;
18
+ }
19
+
20
+ export type WsSubscriber = WsHandle;
21
+ export type WsQueryable = WsHandle;
22
+
23
+ function resolveTopic(pluginId: string, topic: string): string {
24
+ return topic.startsWith("/") ? topic.slice(1) : `${pluginId}/${topic}`;
25
+ }
26
+
27
+ export class RbWs {
28
+ private readonly _socket: Socket;
29
+ private readonly _pluginId: string;
30
+ private readonly _subHandlers = new Map<
31
+ string,
32
+ Set<(payload: unknown) => void>
33
+ >();
34
+ private readonly _serveHandlers = new Map<
35
+ string,
36
+ (payload?: unknown) => unknown | Promise<unknown>
37
+ >();
38
+
39
+ private constructor(socket: Socket, pluginId: string) {
40
+ this._socket = socket;
41
+ this._pluginId = pluginId;
42
+
43
+ // The gateway keeps subscriptions/serve registrations in memory — re-send them after
44
+ // a reconnect (e.g. the gateway restarted), or they silently stop working.
45
+ socket.io.on("reconnect", () => {
46
+ for (const topic of this._subHandlers.keys()) socket.emit("sub", { topic });
47
+ for (const topic of this._serveHandlers.keys()) socket.emit("serve_register", { topic });
48
+ });
49
+
50
+ // Deliver incoming pub/sub messages to registered handlers
51
+ socket.on(
52
+ "msg",
53
+ ({ topic, payload }: { topic: string; payload: unknown }) => {
54
+ this._subHandlers.get(topic)?.forEach((h) => h(payload));
55
+ },
56
+ );
57
+
58
+ // Python calls a browser-side serve handler
59
+ socket.on(
60
+ "serve_call",
61
+ async ({
62
+ id,
63
+ topic,
64
+ payload,
65
+ }: {
66
+ id: string;
67
+ topic: string;
68
+ payload: unknown;
69
+ }) => {
70
+ const handler = this._serveHandlers.get(topic);
71
+ const result = handler ? await handler(payload) : undefined;
72
+ socket.emit("serve_reply", { id, payload: result });
73
+ },
74
+ );
75
+ }
76
+
77
+ get pluginId(): string {
78
+ return this._pluginId;
79
+ }
80
+
81
+ static connect(url: string, pluginId: string): Promise<RbWs> {
82
+ return new Promise((resolve, reject) => {
83
+ const socket = io(url, { transports: ["websocket"] });
84
+ const onConnect = () => {
85
+ cleanup();
86
+ resolve(new RbWs(socket, pluginId));
87
+ };
88
+ const onError = (err: Error) => {
89
+ cleanup();
90
+ reject(err);
91
+ };
92
+ const cleanup = () => {
93
+ socket.off("connect", onConnect);
94
+ socket.off("connect_error", onError);
95
+ };
96
+ socket.once("connect", onConnect);
97
+ socket.once("connect_error", onError);
98
+ });
99
+ }
100
+
101
+ private _key(topic: string): string {
102
+ return resolveTopic(this._pluginId, topic);
103
+ }
104
+
105
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
106
+ async pub<P = any>(topic: string, payload: P): Promise<void> {
107
+ this._socket.emit("pub", { topic: this._key(topic), payload });
108
+ }
109
+
110
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
111
+ async sub<P = any>(
112
+ topic: string,
113
+ handler: (payload: P) => void,
114
+ ): Promise<WsSubscriber> {
115
+ const key = this._key(topic);
116
+ if (!this._subHandlers.has(key)) {
117
+ this._subHandlers.set(key, new Set());
118
+ this._socket.emit("sub", { topic: key });
119
+ }
120
+ const h = handler as (payload: unknown) => void;
121
+ this._subHandlers.get(key)!.add(h);
122
+ return {
123
+ undeclare: () => {
124
+ const set = this._subHandlers.get(key);
125
+ if (!set) return;
126
+ set.delete(h);
127
+ if (set.size === 0) {
128
+ this._subHandlers.delete(key);
129
+ this._socket.emit("unsub", { topic: key });
130
+ }
131
+ },
132
+ };
133
+ }
134
+
135
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
136
+ async action<R = any, P = any>(topic: string, payload?: P): Promise<R> {
137
+ const key = this._key(topic);
138
+ return new Promise((resolve, reject) => {
139
+ this._socket
140
+ .timeout(10_000)
141
+ .emit(
142
+ "action",
143
+ { topic: key, payload },
144
+ (err: Error | null, result: R) => {
145
+ if (err) {
146
+ // socket.io ack timeout — the bridge itself never answered.
147
+ reject(new Error(`action "${key}": no ack from bridge (${err.message})`));
148
+ } else if (
149
+ result &&
150
+ typeof result === "object" &&
151
+ "error" in result &&
152
+ typeof result.error === "string"
153
+ ) {
154
+ // Backend handler returned an error payload — surface it verbatim.
155
+ reject(new Error(result.error));
156
+ } else if (result === undefined || result === null) {
157
+ // The bridge acked but with no payload: the backend handler is
158
+ // registered yet didn't reply in time (hung inside the handler,
159
+ // e.g. a blocked SDK call) or returned nothing. Distinct from a
160
+ // "No handler registered" error, which comes back as result.error.
161
+ reject(
162
+ new Error(
163
+ `action "${key}": no reply from plugin backend — the handler was reached but didn't return in time (check the backend's stdout on the robot).`,
164
+ ),
165
+ );
166
+ } else {
167
+ resolve(result);
168
+ }
169
+ },
170
+ );
171
+ });
172
+ }
173
+
174
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
175
+ async serve<P = any, R = any>(
176
+ topic: string,
177
+ handler: (payload?: P) => R | Promise<R>,
178
+ ): Promise<WsQueryable> {
179
+ const key = this._key(topic);
180
+ this._serveHandlers.set(key, handler as (payload?: unknown) => unknown);
181
+ this._socket.emit("serve_register", { topic: key });
182
+ return {
183
+ undeclare: () => {
184
+ this._serveHandlers.delete(key);
185
+ this._socket.emit("serve_unregister", { topic: key });
186
+ },
187
+ };
188
+ }
189
+
190
+ close(): void {
191
+ this._socket.disconnect();
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Builds an rb-sdk-shaped object (pub/sub/action/serve + the passed-through
197
+ * toast/alert/confirm/prompt) that lazily awaits a not-yet-connected `RbWs` —
198
+ * so callers don't block their first render on the WebSocket handshake (there
199
+ * may be no backend reachable yet). Shared by both host mount paths (see
200
+ * ./host.tsx and muscat-plugin-kit's dev bootstrap), which otherwise
201
+ * duplicate this wiring.
202
+ */
203
+ export function createLazyRbSdk(
204
+ wsPromise: Promise<RbWs>,
205
+ services: PluginUIServices,
206
+ // Per-mount, host-provided (unlike `services`, which is a global singleton):
207
+ // initial `data` and the `onSave` registration the host reads back. Plain
208
+ // values/closures, so they cross the dev React-instance boundary safely.
209
+ mount?: {
210
+ data?: unknown;
211
+ onSave?: (getPayload: () => unknown) => () => void;
212
+ },
213
+ ) {
214
+ return {
215
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
216
+ pub: async (topic: string, payload: any) =>
217
+ (await wsPromise).pub(topic, payload),
218
+ sub: async (
219
+ topic: string,
220
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
221
+ handler: (payload: any) => void,
222
+ ) => (await wsPromise).sub(topic, handler),
223
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
224
+ action: async (topic: string, payload?: any) =>
225
+ (await wsPromise).action(topic, payload),
226
+ serve: async (
227
+ topic: string,
228
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
229
+ handler: (payload?: any) => any,
230
+ ) => (await wsPromise).serve(topic, handler),
231
+ toast: services.toast,
232
+ alert: services.alert,
233
+ confirm: services.confirm,
234
+ prompt: services.prompt,
235
+ program: services.program,
236
+ data: mount?.data,
237
+ onSave: mount?.onSave ?? (() => () => {}),
238
+ rosPeers: rosPeersApi(async (op, payload) => {
239
+ const ws = await wsPromise;
240
+ return ws.action(`/muscat/plugin/${ws.pluginId}/ros_peers/${op}`, payload);
241
+ }),
242
+ };
243
+ }
244
+
245
+ // Answered by Muscat (common) on the robot and by `muscat-pk dev` locally.
246
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
247
+ function rosPeersApi(call: (op: "get" | "set", payload?: any) => Promise<any>) {
248
+ return {
249
+ get: async (): Promise<string[]> => (await call("get"))?.peers ?? [],
250
+ set: async (peers: string[]): Promise<string[]> => {
251
+ const r = await call("set", { peers });
252
+ if (!r?.ok) throw new Error(r?.error ?? "rb.rosPeers.set failed");
253
+ return r.peers;
254
+ },
255
+ };
256
+ }