@rainbow-robotics/plugin-ui 1.10.1-dev.1 → 1.10.2-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/host.tsx CHANGED
@@ -1,277 +1,277 @@
1
- /**
2
- * Host mounting (nexus only). A plugin renders directly in the host document —
3
- * no iframe. Two strategies, per `PluginModuleSource.kind` (see ./pluginData):
4
- *
5
- * - "esm" (built plugin / robot runtime): dynamic `import()` of the plugin's
6
- * built entry. react / react-dom / rb-components / plugin-ui are external bare
7
- * specifiers, resolved by the host page's import map to the host's own loaded
8
- * instances (see ./viteHost.ts). So the plugin shares the host's React,
9
- * rb-components, and RbContext, with no duplicate React instance.
10
- *
11
- * - "dev" (`muscat-pk dev`): the plugin's Vite dev server serves a bootstrap
12
- * module exposing `mount(container, opts)`, mounted as an independent React
13
- * root using the plugin's own deps. HostServices passed in are plain
14
- * functions (see ./host-ui), so they cross the React-instance boundary safely.
15
- */
16
- import {
17
- useCallback,
18
- useEffect,
19
- useMemo,
20
- useRef,
21
- useState,
22
- type ComponentType,
23
- } from "react";
24
- // Import shared modules via the package specifier, NOT relative paths. This
25
- // file ships as source (exports "./host" -> src/host.tsx), while /sdk, /ws,
26
- // /host-ui ship as dist/*.js. A relative "./sdk" would bundle src/sdk.ts,
27
- // whereas the plugin (and nexus's pluginShared) load dist/sdk.js via the
28
- // import map — two RbContext instances, so the host's <RbContext.Provider>
29
- // and the plugin's useRb() would read different contexts and every rb.* call
30
- // would hit the no-op "outside of a host context" fallback.
31
- import type { RbSdk } from "@rainbow-robotics/plugin-ui/sdk";
32
- import { RbContext } from "@rainbow-robotics/plugin-ui/sdk";
33
- import { RbWs, createLazyRbSdk } from "@rainbow-robotics/plugin-ui/ws";
34
- import { getHostServices } from "@rainbow-robotics/plugin-ui/host-ui";
35
- import { usePluginData, type PluginModuleSource } from "./pluginData";
36
-
37
- function pluginRootAttrs(pluginId: string) {
38
- return {
39
- "data-muscat-plugin-root": "",
40
- "data-muscat-plugin-id": pluginId,
41
- };
42
- }
43
-
44
- /* -------------------------------------------------------------------------- */
45
- /* rb sdk — pub/sub/action/serve over the plugin's Zenoh WebSocket */
46
- /* -------------------------------------------------------------------------- */
47
-
48
- /** Per-mount host-provided bits (initial data + save registration). */
49
- type PluginMount = {
50
- data?: unknown;
51
- onSave: (getPayload: () => unknown) => () => void;
52
- };
53
-
54
- function useRbSdk(
55
- socketUrl: string,
56
- pluginId: string,
57
- mount: PluginMount,
58
- ): RbSdk {
59
- const wsRef = useRef<Promise<RbWs> | null>(null);
60
- const keyRef = useRef("");
61
- const key = `${socketUrl}::${pluginId}`;
62
- if (keyRef.current !== key) {
63
- keyRef.current = key;
64
- wsRef.current = RbWs.connect(socketUrl, pluginId);
65
- }
66
-
67
- return useMemo<RbSdk>(
68
- // `mount.data` is captured at first render (stable for a form's lifetime);
69
- // `mount.onSave` is a stable callback. Both intentionally out of deps.
70
- () => createLazyRbSdk(wsRef.current!, getHostServices(), mount),
71
- // eslint-disable-next-line react-hooks/exhaustive-deps
72
- [key],
73
- );
74
- }
75
-
76
- /* -------------------------------------------------------------------------- */
77
- /* ESM mount (prod / robot runtime — import map resolves shared externals) */
78
- /* -------------------------------------------------------------------------- */
79
-
80
- function EsmPluginHost({
81
- source,
82
- socketUrl,
83
- pluginId,
84
- mount,
85
- }: {
86
- source: Extract<PluginModuleSource, { kind: "esm" }>;
87
- socketUrl: string;
88
- pluginId: string;
89
- mount: PluginMount;
90
- }) {
91
- const rb = useRbSdk(socketUrl, pluginId, mount);
92
- const [App, setApp] = useState<ComponentType | null>(null);
93
- const [error, setError] = useState<Error | null>(null);
94
-
95
- useEffect(() => {
96
- let cancelled = false;
97
- setApp(null);
98
- setError(null);
99
- import(/* @vite-ignore */ source.entryUrl)
100
- .then((mod: { default?: ComponentType }) => {
101
- if (cancelled) return;
102
- if (!mod.default) {
103
- throw new Error(
104
- `plugin entry ${source.entryUrl} has no default export`,
105
- );
106
- }
107
- setApp(() => mod.default!);
108
- })
109
- .catch((e: unknown) => {
110
- if (!cancelled) setError(e instanceof Error ? e : new Error(String(e)));
111
- });
112
- return () => {
113
- cancelled = true;
114
- };
115
- }, [source.entryUrl]);
116
-
117
- if (error) {
118
- return (
119
- <div className="text-fg-danger p-4 text-sm">
120
- Failed to load plugin: {error.message}
121
- </div>
122
- );
123
- }
124
- if (!App) {
125
- return (
126
- <div className="flex items-center justify-center p-4">
127
- <div className="border-default size-5 animate-spin rounded-full border-2 border-t-transparent" />
128
- </div>
129
- );
130
- }
131
- return (
132
- <div {...pluginRootAttrs(pluginId)}>
133
- <RbContext.Provider value={rb}>
134
- <App />
135
- </RbContext.Provider>
136
- </div>
137
- );
138
- }
139
-
140
- /* -------------------------------------------------------------------------- */
141
- /* Dev mount (muscat-pk dev) */
142
- /* -------------------------------------------------------------------------- */
143
-
144
- interface DevBootstrapModule {
145
- mount: (
146
- container: HTMLElement,
147
- opts: {
148
- socketUrl: string;
149
- pluginId: string;
150
- hostServices: ReturnType<typeof getHostServices>;
151
- data?: unknown;
152
- onSave: PluginMount["onSave"];
153
- },
154
- ) => () => void;
155
- }
156
-
157
- function DevPluginHost({
158
- source,
159
- socketUrl,
160
- pluginId,
161
- mount,
162
- }: {
163
- source: Extract<PluginModuleSource, { kind: "dev" }>;
164
- socketUrl: string;
165
- pluginId: string;
166
- mount: PluginMount;
167
- }) {
168
- const containerRef = useRef<HTMLDivElement>(null);
169
- const [error, setError] = useState<Error | null>(null);
170
-
171
- useEffect(() => {
172
- const container = containerRef.current;
173
- if (!container) return;
174
- let unmount: (() => void) | undefined;
175
- let cancelled = false;
176
- const deferUnmount = () => queueMicrotask(() => unmount?.());
177
-
178
- import(/* @vite-ignore */ source.bootstrapUrl)
179
- .then((mod: DevBootstrapModule) => {
180
- if (cancelled) return;
181
- unmount = mod.mount(container, {
182
- socketUrl,
183
- pluginId,
184
- hostServices: getHostServices(),
185
- data: mount.data,
186
- onSave: mount.onSave,
187
- });
188
- })
189
- .catch((e: unknown) => {
190
- if (!cancelled) setError(e instanceof Error ? e : new Error(String(e)));
191
- });
192
-
193
- return () => {
194
- cancelled = true;
195
- deferUnmount();
196
- };
197
- }, [source.bootstrapUrl, socketUrl, pluginId]);
198
-
199
- if (error) {
200
- return (
201
- <div className="text-fg-danger p-4 text-sm">
202
- Failed to load plugin (dev): {error.message}
203
- </div>
204
- );
205
- }
206
- return <div ref={containerRef} {...pluginRootAttrs(pluginId)} />;
207
- }
208
-
209
- /* -------------------------------------------------------------------------- */
210
- /* Entry point */
211
- /* -------------------------------------------------------------------------- */
212
-
213
- /** Renders a plugin's UI. Requires a `PluginDataProvider` above. */
214
- export function PluginHost({
215
- pluginId,
216
- ui,
217
- data,
218
- onRegisterSave,
219
- }: {
220
- pluginId: string;
221
- ui: string;
222
- /** Initial data handed to the plugin (e.g. previously-saved form values). */
223
- data?: unknown;
224
- /**
225
- * Called once with a getter the host can invoke (e.g. from a Save button) to
226
- * collect the plugin's current payload — `undefined` until the plugin
227
- * registers via `useRbSave`, or if it never does.
228
- */
229
- onRegisterSave?: (getPayload: () => unknown) => void;
230
- }) {
231
- const { getModuleSource, getSocketUrl } = usePluginData();
232
- const source = useMemo(
233
- () => getModuleSource(pluginId, ui),
234
- [getModuleSource, pluginId, ui],
235
- );
236
- const socketUrl = getSocketUrl(pluginId);
237
-
238
- // The plugin registers its payload getter here; the host reads it back
239
- // through the stable getter handed to `onRegisterSave`.
240
- const saveRef = useRef<(() => unknown) | null>(null);
241
- const registerSave = useCallback((getPayload: () => unknown) => {
242
- saveRef.current = getPayload;
243
- return () => {
244
- if (saveRef.current === getPayload) saveRef.current = null;
245
- };
246
- }, []);
247
-
248
- useEffect(() => {
249
- onRegisterSave?.(() => saveRef.current?.());
250
- }, [onRegisterSave]);
251
-
252
- const mount = useMemo<PluginMount>(
253
- // data captured once (stable per form lifetime); registerSave is stable.
254
- () => ({ data, onSave: registerSave }),
255
- // eslint-disable-next-line react-hooks/exhaustive-deps
256
- [registerSave],
257
- );
258
-
259
- if (source.kind === "dev") {
260
- return (
261
- <DevPluginHost
262
- source={source}
263
- socketUrl={socketUrl}
264
- pluginId={pluginId}
265
- mount={mount}
266
- />
267
- );
268
- }
269
- return (
270
- <EsmPluginHost
271
- source={source}
272
- socketUrl={socketUrl}
273
- pluginId={pluginId}
274
- mount={mount}
275
- />
276
- );
277
- }
1
+ /**
2
+ * Host mounting (nexus only). A plugin renders directly in the host document —
3
+ * no iframe. Two strategies, per `PluginModuleSource.kind` (see ./pluginData):
4
+ *
5
+ * - "esm" (built plugin / robot runtime): dynamic `import()` of the plugin's
6
+ * built entry. react / react-dom / rb-components / plugin-ui are external bare
7
+ * specifiers, resolved by the host page's import map to the host's own loaded
8
+ * instances (see ./viteHost.ts). So the plugin shares the host's React,
9
+ * rb-components, and RbContext, with no duplicate React instance.
10
+ *
11
+ * - "dev" (`muscat-pk dev`): the plugin's Vite dev server serves a bootstrap
12
+ * module exposing `mount(container, opts)`, mounted as an independent React
13
+ * root using the plugin's own deps. HostServices passed in are plain
14
+ * functions (see ./host-ui), so they cross the React-instance boundary safely.
15
+ */
16
+ import {
17
+ useCallback,
18
+ useEffect,
19
+ useMemo,
20
+ useRef,
21
+ useState,
22
+ type ComponentType,
23
+ } from "react";
24
+ // Import shared modules via the package specifier, NOT relative paths. This
25
+ // file ships as source (exports "./host" -> src/host.tsx), while /sdk, /ws,
26
+ // /host-ui ship as dist/*.js. A relative "./sdk" would bundle src/sdk.ts,
27
+ // whereas the plugin (and nexus's pluginShared) load dist/sdk.js via the
28
+ // import map — two RbContext instances, so the host's <RbContext.Provider>
29
+ // and the plugin's useRb() would read different contexts and every rb.* call
30
+ // would hit the no-op "outside of a host context" fallback.
31
+ import type { RbSdk } from "@rainbow-robotics/plugin-ui/sdk";
32
+ import { RbContext } from "@rainbow-robotics/plugin-ui/sdk";
33
+ import { RbWs, createLazyRbSdk } from "@rainbow-robotics/plugin-ui/ws";
34
+ import { getHostServices } from "@rainbow-robotics/plugin-ui/host-ui";
35
+ import { usePluginData, type PluginModuleSource } from "./pluginData";
36
+
37
+ function pluginRootAttrs(pluginId: string) {
38
+ return {
39
+ "data-muscat-plugin-root": "",
40
+ "data-muscat-plugin-id": pluginId,
41
+ };
42
+ }
43
+
44
+ /* -------------------------------------------------------------------------- */
45
+ /* rb sdk — pub/sub/action/serve over the plugin's Zenoh WebSocket */
46
+ /* -------------------------------------------------------------------------- */
47
+
48
+ /** Per-mount host-provided bits (initial data + save registration). */
49
+ type PluginMount = {
50
+ data?: unknown;
51
+ onSave: (getPayload: () => unknown) => () => void;
52
+ };
53
+
54
+ function useRbSdk(
55
+ socketUrl: string,
56
+ pluginId: string,
57
+ mount: PluginMount,
58
+ ): RbSdk {
59
+ const wsRef = useRef<Promise<RbWs> | null>(null);
60
+ const keyRef = useRef("");
61
+ const key = `${socketUrl}::${pluginId}`;
62
+ if (keyRef.current !== key) {
63
+ keyRef.current = key;
64
+ wsRef.current = RbWs.connect(socketUrl, pluginId);
65
+ }
66
+
67
+ return useMemo<RbSdk>(
68
+ // `mount.data` is captured at first render (stable for a form's lifetime);
69
+ // `mount.onSave` is a stable callback. Both intentionally out of deps.
70
+ () => createLazyRbSdk(wsRef.current!, getHostServices(), mount),
71
+ // eslint-disable-next-line react-hooks/exhaustive-deps
72
+ [key],
73
+ );
74
+ }
75
+
76
+ /* -------------------------------------------------------------------------- */
77
+ /* ESM mount (prod / robot runtime — import map resolves shared externals) */
78
+ /* -------------------------------------------------------------------------- */
79
+
80
+ function EsmPluginHost({
81
+ source,
82
+ socketUrl,
83
+ pluginId,
84
+ mount,
85
+ }: {
86
+ source: Extract<PluginModuleSource, { kind: "esm" }>;
87
+ socketUrl: string;
88
+ pluginId: string;
89
+ mount: PluginMount;
90
+ }) {
91
+ const rb = useRbSdk(socketUrl, pluginId, mount);
92
+ const [App, setApp] = useState<ComponentType | null>(null);
93
+ const [error, setError] = useState<Error | null>(null);
94
+
95
+ useEffect(() => {
96
+ let cancelled = false;
97
+ setApp(null);
98
+ setError(null);
99
+ import(/* @vite-ignore */ source.entryUrl)
100
+ .then((mod: { default?: ComponentType }) => {
101
+ if (cancelled) return;
102
+ if (!mod.default) {
103
+ throw new Error(
104
+ `plugin entry ${source.entryUrl} has no default export`,
105
+ );
106
+ }
107
+ setApp(() => mod.default!);
108
+ })
109
+ .catch((e: unknown) => {
110
+ if (!cancelled) setError(e instanceof Error ? e : new Error(String(e)));
111
+ });
112
+ return () => {
113
+ cancelled = true;
114
+ };
115
+ }, [source.entryUrl]);
116
+
117
+ if (error) {
118
+ return (
119
+ <div className="text-fg-danger p-4 text-sm">
120
+ Failed to load plugin: {error.message}
121
+ </div>
122
+ );
123
+ }
124
+ if (!App) {
125
+ return (
126
+ <div className="flex items-center justify-center p-4">
127
+ <div className="border-default size-5 animate-spin rounded-full border-2 border-t-transparent" />
128
+ </div>
129
+ );
130
+ }
131
+ return (
132
+ <div {...pluginRootAttrs(pluginId)}>
133
+ <RbContext.Provider value={rb}>
134
+ <App />
135
+ </RbContext.Provider>
136
+ </div>
137
+ );
138
+ }
139
+
140
+ /* -------------------------------------------------------------------------- */
141
+ /* Dev mount (muscat-pk dev) */
142
+ /* -------------------------------------------------------------------------- */
143
+
144
+ interface DevBootstrapModule {
145
+ mount: (
146
+ container: HTMLElement,
147
+ opts: {
148
+ socketUrl: string;
149
+ pluginId: string;
150
+ hostServices: ReturnType<typeof getHostServices>;
151
+ data?: unknown;
152
+ onSave: PluginMount["onSave"];
153
+ },
154
+ ) => () => void;
155
+ }
156
+
157
+ function DevPluginHost({
158
+ source,
159
+ socketUrl,
160
+ pluginId,
161
+ mount,
162
+ }: {
163
+ source: Extract<PluginModuleSource, { kind: "dev" }>;
164
+ socketUrl: string;
165
+ pluginId: string;
166
+ mount: PluginMount;
167
+ }) {
168
+ const containerRef = useRef<HTMLDivElement>(null);
169
+ const [error, setError] = useState<Error | null>(null);
170
+
171
+ useEffect(() => {
172
+ const container = containerRef.current;
173
+ if (!container) return;
174
+ let unmount: (() => void) | undefined;
175
+ let cancelled = false;
176
+ const deferUnmount = () => queueMicrotask(() => unmount?.());
177
+
178
+ import(/* @vite-ignore */ source.bootstrapUrl)
179
+ .then((mod: DevBootstrapModule) => {
180
+ if (cancelled) return;
181
+ unmount = mod.mount(container, {
182
+ socketUrl,
183
+ pluginId,
184
+ hostServices: getHostServices(),
185
+ data: mount.data,
186
+ onSave: mount.onSave,
187
+ });
188
+ })
189
+ .catch((e: unknown) => {
190
+ if (!cancelled) setError(e instanceof Error ? e : new Error(String(e)));
191
+ });
192
+
193
+ return () => {
194
+ cancelled = true;
195
+ deferUnmount();
196
+ };
197
+ }, [source.bootstrapUrl, socketUrl, pluginId]);
198
+
199
+ if (error) {
200
+ return (
201
+ <div className="text-fg-danger p-4 text-sm">
202
+ Failed to load plugin (dev): {error.message}
203
+ </div>
204
+ );
205
+ }
206
+ return <div ref={containerRef} {...pluginRootAttrs(pluginId)} />;
207
+ }
208
+
209
+ /* -------------------------------------------------------------------------- */
210
+ /* Entry point */
211
+ /* -------------------------------------------------------------------------- */
212
+
213
+ /** Renders a plugin's UI. Requires a `PluginDataProvider` above. */
214
+ export function PluginHost({
215
+ pluginId,
216
+ ui,
217
+ data,
218
+ onRegisterSave,
219
+ }: {
220
+ pluginId: string;
221
+ ui: string;
222
+ /** Initial data handed to the plugin (e.g. previously-saved form values). */
223
+ data?: unknown;
224
+ /**
225
+ * Called once with a getter the host can invoke (e.g. from a Save button) to
226
+ * collect the plugin's current payload — `undefined` until the plugin
227
+ * registers via `useRbSave`, or if it never does.
228
+ */
229
+ onRegisterSave?: (getPayload: () => unknown) => void;
230
+ }) {
231
+ const { getModuleSource, getSocketUrl } = usePluginData();
232
+ const source = useMemo(
233
+ () => getModuleSource(pluginId, ui),
234
+ [getModuleSource, pluginId, ui],
235
+ );
236
+ const socketUrl = getSocketUrl(pluginId);
237
+
238
+ // The plugin registers its payload getter here; the host reads it back
239
+ // through the stable getter handed to `onRegisterSave`.
240
+ const saveRef = useRef<(() => unknown) | null>(null);
241
+ const registerSave = useCallback((getPayload: () => unknown) => {
242
+ saveRef.current = getPayload;
243
+ return () => {
244
+ if (saveRef.current === getPayload) saveRef.current = null;
245
+ };
246
+ }, []);
247
+
248
+ useEffect(() => {
249
+ onRegisterSave?.(() => saveRef.current?.());
250
+ }, [onRegisterSave]);
251
+
252
+ const mount = useMemo<PluginMount>(
253
+ // data captured once (stable per form lifetime); registerSave is stable.
254
+ () => ({ data, onSave: registerSave }),
255
+ // eslint-disable-next-line react-hooks/exhaustive-deps
256
+ [registerSave],
257
+ );
258
+
259
+ if (source.kind === "dev") {
260
+ return (
261
+ <DevPluginHost
262
+ source={source}
263
+ socketUrl={socketUrl}
264
+ pluginId={pluginId}
265
+ mount={mount}
266
+ />
267
+ );
268
+ }
269
+ return (
270
+ <EsmPluginHost
271
+ source={source}
272
+ socketUrl={socketUrl}
273
+ pluginId={pluginId}
274
+ mount={mount}
275
+ />
276
+ );
277
+ }
package/src/index.d.ts CHANGED
@@ -1 +1 @@
1
- /// <reference types="@repo/typescript-config" />
1
+ /// <reference types="@repo/typescript-config" />