@rainbow-robotics/plugin-ui 0.1.2-dev.5 → 0.1.2-dev.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rainbow-robotics/plugin-ui",
3
- "version": "0.1.2-dev.5",
3
+ "version": "0.1.2-dev.6",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=20"
@@ -56,6 +56,7 @@
56
56
  "typescript": "^5.8.0"
57
57
  },
58
58
  "dependencies": {
59
+ "@eclipse-zenoh/zenoh-ts": "^1.9.0",
59
60
  "@quilted/threads": "^4.0.3",
60
61
  "@remote-dom/core": "^1.11.1",
61
62
  "@remote-dom/react": "^1.2.2"
package/src/connect.ts CHANGED
@@ -1,17 +1,9 @@
1
1
  /**
2
2
  * Sandbox bootstrap — runs *inside* the plugin iframe.
3
3
  *
4
- * The sandbox initiates the handshake (the iframe is the side that knows when it
5
- * is ready): it imports the host's `connect()` and calls it. The host responds
6
- * with a `RemoteConnection` (its `receiver.connection`, ferried by
7
- * @quilted/threads) plus the host SDK. We then render the plugin's React tree
8
- * into a detached root and let `RemoteMutationObserver` stream every DOM
9
- * mutation to the host, where the real rb-components live.
10
- *
11
- * The child-initiates-handshake pattern is required because @quilted/threads
12
- * does not buffer a call made before the other side is listening; the host has
13
- * been listening since it mounted the iframe, so a call *from* the sandbox is
14
- * always delivered.
4
+ * The sandbox initiates the handshake: imports the host's `connect()` and calls
5
+ * it. The host responds with the RemoteConnection, toast(), and Zenoh config
6
+ * (url + pluginId). We open a Zenoh session and wire up the rb object.
15
7
  *
16
8
  * Verified against @remote-dom/core@1.11.x + @quilted/threads@4.x.
17
9
  */
@@ -22,23 +14,23 @@ import { createElement, type ComponentType } from "react";
22
14
  import { createRoot } from "react-dom/client";
23
15
  import { defineElements } from "./elements";
24
16
  import { RbContext, type RbSdk } from "./sdk";
17
+ import { RbZenoh } from "./zenoh";
25
18
 
26
- /** What the host exposes to the sandbox. */
27
19
  interface HostExports {
28
20
  connect: () => Promise<{
29
21
  connection: RemoteConnection;
30
- action: (id: string, payload?: unknown) => Promise<unknown> | unknown;
31
- notify: (message: string, type?: string) => void;
22
+ toast: (message: string, type?: string) => void;
23
+ zenohUrl: string;
24
+ pluginId: string;
32
25
  }>;
33
26
  }
34
27
 
35
28
  /**
36
- * Mount a plugin. Call this once from the sandbox entry (sandbox.html), passing
37
- * the plugin's root component.
29
+ * Mount a plugin. Call this once from the sandbox entry (content.tsx).
38
30
  *
39
31
  * @example
40
32
  * import { connect } from "@rainbow-robotics/plugin-ui/connect";
41
- * import App from "./content";
33
+ * import App from "./App";
42
34
  * connect(App);
43
35
  */
44
36
  export function connect(App: ComponentType): void {
@@ -48,14 +40,18 @@ export function connect(App: ComponentType): void {
48
40
  targetOrigin: "*",
49
41
  });
50
42
 
51
- void host.connect().then(({ connection, action, notify }) => {
43
+ void host.connect().then(async ({ connection, toast, zenohUrl, pluginId }) => {
44
+ const zenoh = await RbZenoh.open(zenohUrl, pluginId);
45
+
52
46
  const rb: RbSdk = {
53
- action: (id, payload) => Promise.resolve(action(id, payload)),
54
- notify: (message, type) => notify(message, type as string | undefined),
47
+ pub: (topic, payload) => zenoh.pub(topic, payload),
48
+ sub: (topic, handler) => zenoh.sub(topic, handler),
49
+ action: (topic, payload) => zenoh.action(topic, payload),
50
+ serve: (topic, handler) => zenoh.serve(topic, handler),
51
+ toast: (message, type) => toast(message, type),
55
52
  };
56
53
 
57
54
  const root = document.createElement("remote-root");
58
-
59
55
  document.body.append(root);
60
56
 
61
57
  const observer = new RemoteMutationObserver(connection);
package/src/sdk.ts CHANGED
@@ -1,35 +1,68 @@
1
1
  /**
2
- * Plugin runtime SDK — the small, stable surface a plugin author calls from
3
- * inside the sandbox to talk to the host (Muscat / nexus).
2
+ * Plugin runtime SDK — the surface a plugin author calls from inside the sandbox.
4
3
  *
5
- * The concrete implementation is injected by the host across the iframe thread
6
- * (see ./connect for the sandbox side, ./host for the host side).
4
+ * Zenoh methods (pub/sub/action/serve) talk directly to the Zenoh router via
5
+ * WebSocket. Topics are auto-namespaced to plugin/{pluginId}/... to prevent
6
+ * cross-plugin collisions. A leading "/" bypasses namespacing (absolute path).
7
+ *
8
+ * notify() is still host-mediated (goes through the Muscat shell).
7
9
  */
8
10
  import { createContext, useContext } from "react";
11
+ import type { Subscriber, Queryable } from "./zenoh";
9
12
 
10
13
  export type RbNotifyType = "info" | "success" | "warning" | "error";
11
14
 
12
15
  export interface RbSdk {
13
16
  /**
14
- * Invoke a host-side action (declared in manifest.actions). Resolves with
15
- * whatever the host/back end returns.
17
+ * Publish JSON payload to a topic.
18
+ * "sensor/data" → plugin/{pluginId}/sensor/data
19
+ * "/broadcast/x" → broadcast/x (absolute)
20
+ */
21
+ pub: <P = any>(topic: string, payload: P) => Promise<void>;
22
+
23
+ /** Subscribe to a topic. Returns a handle with undeclare(). */
24
+ sub: <P = any>(
25
+ topic: string,
26
+ handler: (payload: P) => void,
27
+ ) => Promise<Subscriber>;
28
+
29
+ /**
30
+ * Send a request and await the first reply (Zenoh query).
31
+ * Use this to call a backend handler registered with rb.serve() / ZenohRouter.
32
+ */
33
+ action: <R = any, P = any>(topic: string, payload?: P) => Promise<R>;
34
+
35
+ /**
36
+ * Register a queryable — the frontend responds to backend queries.
37
+ * Mirror of rb.action() on the other side.
16
38
  */
17
- action: (id: string, payload?: unknown) => Promise<unknown>;
39
+ serve: <P = any, R = any>(
40
+ topic: string,
41
+ handler: (payload?: P) => R | Promise<R>,
42
+ ) => Promise<Queryable>;
43
+
18
44
  /** Show a transient notification through the host's toast system. */
19
- notify: (message: string, type?: RbNotifyType) => void;
45
+ toast: (message: string, type?: RbNotifyType) => void;
20
46
  }
21
47
 
22
48
  const noopSdk: RbSdk = {
23
- action: async () => {
24
- if (typeof console !== "undefined") {
25
- console.warn("[plugin-ui] rb.action called outside of a host context");
26
- }
27
- return undefined;
49
+ pub: async <P = any>(_topic?: string, _payload?: P) => {
50
+ console.warn("[plugin-ui] rb.pub called outside of a host context");
51
+ },
52
+ sub: async <P = any>(_topic?: string, _handler?: (payload: P) => void) => {
53
+ console.warn("[plugin-ui] rb.sub called outside of a host context");
54
+ return { undeclare: async () => {} } as unknown as Subscriber;
55
+ },
56
+ action: async <R = any, P = any>(_topic?: string, _payload?: P) => {
57
+ console.warn("[plugin-ui] rb.action called outside of a host context");
58
+ return undefined as R;
59
+ },
60
+ serve: async <P = any, R = any>(_topic?: string, _handler?: (payload?: P) => R) => {
61
+ console.warn("[plugin-ui] rb.serve called outside of a host context");
62
+ return { undeclare: async () => {} } as unknown as Queryable;
28
63
  },
29
- notify: (message) => {
30
- if (typeof console !== "undefined") {
31
- console.warn("[plugin-ui] rb.notify (no host):", message);
32
- }
64
+ toast: (message) => {
65
+ console.warn("[plugin-ui] rb.toast (no host):", message);
33
66
  },
34
67
  };
35
68
 
package/src/zenoh.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Zenoh communication client for plugin frontends.
3
+ * All topics are automatically namespaced to prevent cross-plugin collisions:
4
+ * "sensor/data" → "plugin/{pluginId}/sensor/data"
5
+ * "/broadcast/x" → "broadcast/x" (leading slash = absolute)
6
+ */
7
+ import { Session, Config } from "@eclipse-zenoh/zenoh-ts";
8
+ import type { Publisher, Subscriber, Queryable } from "@eclipse-zenoh/zenoh-ts";
9
+
10
+ export type { Subscriber, Queryable };
11
+
12
+ export interface ZenohHandle {
13
+ undeclare(): Promise<void>;
14
+ }
15
+
16
+ function resolveTopic(pluginId: string, topic: string): string {
17
+ return topic.startsWith("/") ? topic.slice(1) : `plugin/${pluginId}/${topic}`;
18
+ }
19
+
20
+ function encode(payload: unknown): string {
21
+ return JSON.stringify(payload);
22
+ }
23
+
24
+ function decode(raw: string | Uint8Array): unknown {
25
+ const str = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
26
+ try {
27
+ return JSON.parse(str);
28
+ } catch {
29
+ return str;
30
+ }
31
+ }
32
+
33
+ export class RbZenoh {
34
+ private readonly _session: Session;
35
+ private readonly _pluginId: string;
36
+ private readonly _pubs = new Map<string, Publisher>();
37
+
38
+ private constructor(session: Session, pluginId: string) {
39
+ this._session = session;
40
+ this._pluginId = pluginId;
41
+ }
42
+
43
+ static async open(url: string, pluginId: string): Promise<RbZenoh> {
44
+ const session = await Session.open(new Config(url));
45
+ return new RbZenoh(session, pluginId);
46
+ }
47
+
48
+ private _key(topic: string): string {
49
+ return resolveTopic(this._pluginId, topic);
50
+ }
51
+
52
+ /** Publish JSON payload to a topic. Publisher is cached per topic. */
53
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
54
+ async pub<P = any>(topic: string, payload: P): Promise<void> {
55
+ const key = this._key(topic);
56
+ let publisher = this._pubs.get(key);
57
+ if (!publisher) {
58
+ publisher = await this._session.declarePublisher(key);
59
+ this._pubs.set(key, publisher);
60
+ }
61
+ await publisher.put(encode(payload));
62
+ }
63
+
64
+ /** Subscribe to a topic. Returns a handle with undeclare(). */
65
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
66
+ async sub<P = any>(
67
+ topic: string,
68
+ handler: (payload: P) => void,
69
+ ): Promise<Subscriber> {
70
+ return this._session.declareSubscriber(this._key(topic), {
71
+ handler: (sample) => {
72
+ handler(decode(sample.payload().toBytes()) as P);
73
+ },
74
+ });
75
+ }
76
+
77
+ /**
78
+ * Send a request and await the first reply (Zenoh query).
79
+ * Equivalent to a remote procedure call.
80
+ */
81
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
82
+ async action<R = any, P = any>(topic: string, payload?: P): Promise<R> {
83
+ const opts =
84
+ payload !== undefined ? { payload: encode(payload) } : undefined;
85
+ const receiver = await this._session.get(this._key(topic), opts);
86
+ if (!receiver) return undefined;
87
+ for await (const reply of receiver) {
88
+ const result = reply.result();
89
+ if (result && "payload" in result) {
90
+ return decode(result.payload().toBytes()) as R;
91
+ }
92
+ }
93
+ return undefined;
94
+ }
95
+
96
+ /**
97
+ * Register a queryable handler (Zenoh queryable).
98
+ * The backend calls action(), this side responds.
99
+ */
100
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
101
+ async serve<P = any, R = any>(
102
+ topic: string,
103
+ handler: (payload?: P) => R | Promise<R>,
104
+ ): Promise<Queryable> {
105
+ const key = this._key(topic);
106
+ return this._session.declareQueryable(key, {
107
+ handler: async (query) => {
108
+ const reqPayload = query.payload()
109
+ ? (decode(query.payload()!.toBytes()) as P)
110
+ : undefined;
111
+ const result = await handler(reqPayload);
112
+ await query.reply(key, encode(result));
113
+ },
114
+ });
115
+ }
116
+
117
+ async close(): Promise<void> {
118
+ for (const pub of this._pubs.values()) await pub.undeclare();
119
+ this._pubs.clear();
120
+ await this._session.close();
121
+ }
122
+ }
@@ -1 +0,0 @@
1
- var g=Object.defineProperty;var f=a=>{throw TypeError(a)};var h=(a,b,c)=>b in a?g(a,b,{enumerable:!0,configurable:!0,writable:!0,value:c}):a[b]=c;var i=(a,b,c)=>h(a,typeof b!="symbol"?b+"":b,c),d=(a,b,c)=>b.has(a)||f("Cannot "+c);var j=(a,b,c)=>(d(a,b,"read from private field"),c?c.call(a):b.get(a)),k=(a,b,c)=>b.has(a)?f("Cannot add the same private member more than once"):b instanceof WeakSet?b.add(a):b.set(a,c),l=(a,b,c,e)=>(d(a,b,"write to private field"),e?e.call(a,c):b.set(a,c),c),m=(a,b,c)=>(d(a,b,"access private method"),c);export{i as a,j as b,k as c,l as d,m as e};
@@ -1 +0,0 @@
1
- import{createContext as o,useContext as e}from"react";var t={action:async()=>{typeof console<"u"&&console.warn("[plugin-ui] rb.action called outside of a host context")},notify:n=>{typeof console<"u"&&console.warn("[plugin-ui] rb.notify (no host):",n)}},i=o(t);function s(){return e(i)}export{i as a,s as b};