Spaces:
Paused
Paused
| /** | |
| * Plugin UI bridge runtime — concrete implementations of the bridge hooks. | |
| * | |
| * Plugin UI bundles import `usePluginData`, `usePluginAction`, and | |
| * `useHostContext` from `@paperclipai/plugin-sdk/ui`. Those are type-only | |
| * declarations in the SDK package. The host provides the real implementations | |
| * by injecting this bridge runtime into the plugin's module scope. | |
| * | |
| * The bridge runtime communicates with plugin workers via HTTP REST endpoints: | |
| * - `POST /api/plugins/:pluginId/data/:key` — proxies `getData` RPC | |
| * - `POST /api/plugins/:pluginId/actions/:key` — proxies `performAction` RPC | |
| * | |
| * ## How it works | |
| * | |
| * 1. Before loading a plugin's UI module, the host creates a scoped bridge via | |
| * `createPluginBridge(pluginId)`. | |
| * 2. The bridge's hook implementations are registered in a global bridge | |
| * registry keyed by `pluginId`. | |
| * 3. The "ambient" hooks (`usePluginData`, `usePluginAction`, `useHostContext`) | |
| * look up the current plugin context from a React context provider and | |
| * delegate to the appropriate bridge instance. | |
| * | |
| * @see PLUGIN_SPEC.md §13.8 — `getData` | |
| * @see PLUGIN_SPEC.md §13.9 — `performAction` | |
| * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge | |
| */ | |
| import { createContext, useCallback, useContext, useRef, useState, useEffect } from "react"; | |
| import type { | |
| PluginBridgeErrorCode, | |
| PluginLauncherBounds, | |
| PluginLauncherRenderContextSnapshot, | |
| PluginLauncherRenderEnvironment, | |
| } from "@paperclipai/shared"; | |
| import { pluginsApi } from "@/api/plugins"; | |
| import { ApiError } from "@/api/client"; | |
| import { useToast, type ToastInput } from "@/context/ToastContext"; | |
| // --------------------------------------------------------------------------- | |
| // Bridge error type (mirrors the SDK's PluginBridgeError) | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Structured error from the bridge, matching the SDK's `PluginBridgeError`. | |
| */ | |
| export interface PluginBridgeError { | |
| code: PluginBridgeErrorCode; | |
| message: string; | |
| details?: unknown; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Bridge data result type (mirrors the SDK's PluginDataResult) | |
| // --------------------------------------------------------------------------- | |
| export interface PluginDataResult<T = unknown> { | |
| data: T | null; | |
| loading: boolean; | |
| error: PluginBridgeError | null; | |
| refresh(): void; | |
| } | |
| export type PluginToastInput = ToastInput; | |
| export type PluginToastFn = (input: PluginToastInput) => string | null; | |
| // --------------------------------------------------------------------------- | |
| // Host context type (mirrors the SDK's PluginHostContext) | |
| // --------------------------------------------------------------------------- | |
| export interface PluginHostContext { | |
| companyId: string | null; | |
| companyPrefix: string | null; | |
| projectId: string | null; | |
| entityId: string | null; | |
| entityType: string | null; | |
| parentEntityId?: string | null; | |
| userId: string | null; | |
| renderEnvironment?: PluginRenderEnvironmentContext | null; | |
| } | |
| export interface PluginModalBoundsRequest { | |
| bounds: PluginLauncherBounds; | |
| width?: number; | |
| height?: number; | |
| minWidth?: number; | |
| minHeight?: number; | |
| maxWidth?: number; | |
| maxHeight?: number; | |
| } | |
| export interface PluginRenderCloseEvent { | |
| reason: | |
| | "escapeKey" | |
| | "backdrop" | |
| | "hostNavigation" | |
| | "programmatic" | |
| | "submit" | |
| | "unknown"; | |
| nativeEvent?: unknown; | |
| } | |
| export type PluginRenderCloseHandler = ( | |
| event: PluginRenderCloseEvent, | |
| ) => void | Promise<void>; | |
| export interface PluginRenderCloseLifecycle { | |
| onBeforeClose?(handler: PluginRenderCloseHandler): () => void; | |
| onClose?(handler: PluginRenderCloseHandler): () => void; | |
| } | |
| export interface PluginRenderEnvironmentContext { | |
| environment: PluginLauncherRenderEnvironment | null; | |
| launcherId: string | null; | |
| bounds: PluginLauncherBounds | null; | |
| requestModalBounds?(request: PluginModalBoundsRequest): Promise<void>; | |
| closeLifecycle?: PluginRenderCloseLifecycle | null; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Bridge context — React context for plugin identity and host scope | |
| // --------------------------------------------------------------------------- | |
| export type PluginBridgeContextValue = { | |
| pluginId: string; | |
| hostContext: PluginHostContext; | |
| }; | |
| /** | |
| * React context that carries the active plugin identity and host scope. | |
| * | |
| * The slot/launcher mount wraps plugin components in a Provider so that | |
| * bridge hooks (`usePluginData`, `usePluginAction`, `useHostContext`) can | |
| * resolve the current plugin without ambient mutable globals. | |
| * | |
| * Because plugin bundles share the host's React instance (via the bridge | |
| * registry on `globalThis.__paperclipPluginBridge__`), context propagation | |
| * works correctly across the host/plugin boundary. | |
| */ | |
| export const PluginBridgeContext = | |
| createContext<PluginBridgeContextValue | null>(null); | |
| function usePluginBridgeContext(): PluginBridgeContextValue { | |
| const ctx = useContext(PluginBridgeContext); | |
| if (!ctx) { | |
| throw new Error( | |
| "Plugin bridge hook called outside of a <PluginBridgeContext.Provider>. " + | |
| "Ensure the plugin component is rendered within a PluginBridgeScope.", | |
| ); | |
| } | |
| return ctx; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Error extraction helpers | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Attempt to extract a structured PluginBridgeError from an API error. | |
| * | |
| * The bridge proxy endpoints return error bodies shaped as | |
| * `{ code: PluginBridgeErrorCode, message: string, details?: unknown }`. | |
| * This helper extracts that structure from the ApiError thrown by the client. | |
| */ | |
| function extractBridgeError(err: unknown): PluginBridgeError { | |
| if (err instanceof ApiError && err.body && typeof err.body === "object") { | |
| const body = err.body as Record<string, unknown>; | |
| if (typeof body.code === "string" && typeof body.message === "string") { | |
| return { | |
| code: body.code as PluginBridgeErrorCode, | |
| message: body.message, | |
| details: body.details, | |
| }; | |
| } | |
| // Fallback: the server returned a plain { error: string } body | |
| if (typeof body.error === "string") { | |
| return { | |
| code: "UNKNOWN", | |
| message: body.error, | |
| }; | |
| } | |
| } | |
| return { | |
| code: "UNKNOWN", | |
| message: err instanceof Error ? err.message : String(err), | |
| }; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // usePluginData — concrete implementation | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Stable serialization of params for use as a dependency key. | |
| * Returns a string that changes only when the params object content changes. | |
| */ | |
| function serializeParams(params?: Record<string, unknown>): string { | |
| if (!params) return ""; | |
| try { | |
| return JSON.stringify(params, Object.keys(params).sort()); | |
| } catch { | |
| return ""; | |
| } | |
| } | |
| function serializeRenderEnvironment( | |
| renderEnvironment?: PluginRenderEnvironmentContext | null, | |
| ): PluginLauncherRenderContextSnapshot | null { | |
| if (!renderEnvironment) return null; | |
| return { | |
| environment: renderEnvironment.environment, | |
| launcherId: renderEnvironment.launcherId, | |
| bounds: renderEnvironment.bounds, | |
| }; | |
| } | |
| function serializeRenderEnvironmentSnapshot( | |
| snapshot: PluginLauncherRenderContextSnapshot | null, | |
| ): string { | |
| return snapshot ? JSON.stringify(snapshot) : ""; | |
| } | |
| /** | |
| * Concrete implementation of `usePluginData<T>(key, params)`. | |
| * | |
| * Makes an HTTP POST to `/api/plugins/:pluginId/data/:key` and returns | |
| * a reactive `PluginDataResult<T>` matching the SDK type contract. | |
| * | |
| * Re-fetches automatically when `key` or `params` change. Provides a | |
| * `refresh()` function for manual re-fetch. | |
| */ | |
| export function usePluginData<T = unknown>( | |
| key: string, | |
| params?: Record<string, unknown>, | |
| ): PluginDataResult<T> { | |
| const { pluginId, hostContext } = usePluginBridgeContext(); | |
| const companyId = hostContext.companyId; | |
| const renderEnvironmentSnapshot = serializeRenderEnvironment(hostContext.renderEnvironment); | |
| const renderEnvironmentKey = serializeRenderEnvironmentSnapshot(renderEnvironmentSnapshot); | |
| const [data, setData] = useState<T | null>(null); | |
| const [loading, setLoading] = useState(true); | |
| const [error, setError] = useState<PluginBridgeError | null>(null); | |
| const [refreshCounter, setRefreshCounter] = useState(0); | |
| // Stable serialization for params change detection | |
| const paramsKey = serializeParams(params); | |
| useEffect(() => { | |
| let cancelled = false; | |
| let retryTimer: ReturnType<typeof setTimeout> | null = null; | |
| let retryCount = 0; | |
| const maxRetryCount = 2; | |
| const retryableCodes: PluginBridgeErrorCode[] = ["WORKER_UNAVAILABLE", "TIMEOUT"]; | |
| setLoading(true); | |
| const request = () => { | |
| pluginsApi | |
| .bridgeGetData( | |
| pluginId, | |
| key, | |
| params, | |
| companyId, | |
| renderEnvironmentSnapshot, | |
| ) | |
| .then((response) => { | |
| if (!cancelled) { | |
| setData(response.data as T); | |
| setError(null); | |
| setLoading(false); | |
| } | |
| }) | |
| .catch((err: unknown) => { | |
| if (cancelled) return; | |
| const bridgeError = extractBridgeError(err); | |
| if (retryableCodes.includes(bridgeError.code) && retryCount < maxRetryCount) { | |
| retryCount += 1; | |
| retryTimer = setTimeout(() => { | |
| retryTimer = null; | |
| if (!cancelled) request(); | |
| }, 150 * retryCount); | |
| return; | |
| } | |
| setError(bridgeError); | |
| setData(null); | |
| setLoading(false); | |
| }); | |
| }; | |
| request(); | |
| return () => { | |
| cancelled = true; | |
| if (retryTimer) clearTimeout(retryTimer); | |
| }; | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [pluginId, key, paramsKey, refreshCounter, companyId, renderEnvironmentKey]); | |
| const refresh = useCallback(() => { | |
| setRefreshCounter((c) => c + 1); | |
| }, []); | |
| return { data, loading, error, refresh }; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // usePluginAction — concrete implementation | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Action function type matching the SDK's `PluginActionFn`. | |
| */ | |
| export type PluginActionFn = (params?: Record<string, unknown>) => Promise<unknown>; | |
| /** | |
| * Concrete implementation of `usePluginAction(key)`. | |
| * | |
| * Returns a stable async function that, when called, sends a POST to | |
| * `/api/plugins/:pluginId/actions/:key` and returns the worker result. | |
| * | |
| * On failure, the function throws a `PluginBridgeError`. | |
| */ | |
| export function usePluginAction(key: string): PluginActionFn { | |
| const bridgeContext = usePluginBridgeContext(); | |
| const contextRef = useRef(bridgeContext); | |
| contextRef.current = bridgeContext; | |
| return useCallback( | |
| async (params?: Record<string, unknown>): Promise<unknown> => { | |
| const { pluginId, hostContext } = contextRef.current; | |
| const companyId = hostContext.companyId; | |
| const renderEnvironment = serializeRenderEnvironment(hostContext.renderEnvironment); | |
| try { | |
| const response = await pluginsApi.bridgePerformAction( | |
| pluginId, | |
| key, | |
| params, | |
| companyId, | |
| renderEnvironment, | |
| ); | |
| return response.data; | |
| } catch (err) { | |
| throw extractBridgeError(err); | |
| } | |
| }, | |
| [key], | |
| ); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // useHostContext — concrete implementation | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Concrete implementation of `useHostContext()`. | |
| * | |
| * Returns the current host context (company, project, entity, user) | |
| * from the enclosing `PluginBridgeContext.Provider`. | |
| */ | |
| export function useHostContext(): PluginHostContext { | |
| const { hostContext } = usePluginBridgeContext(); | |
| return hostContext; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // usePluginToast — concrete implementation | |
| // --------------------------------------------------------------------------- | |
| export function usePluginToast(): PluginToastFn { | |
| const { pushToast } = useToast(); | |
| return useCallback( | |
| (input: PluginToastInput) => pushToast(input), | |
| [pushToast], | |
| ); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // usePluginStream — concrete implementation | |
| // --------------------------------------------------------------------------- | |
| export interface PluginStreamResult<T = unknown> { | |
| events: T[]; | |
| lastEvent: T | null; | |
| connecting: boolean; | |
| connected: boolean; | |
| error: Error | null; | |
| close(): void; | |
| } | |
| export function usePluginStream<T = unknown>( | |
| channel: string, | |
| options?: { companyId?: string }, | |
| ): PluginStreamResult<T> { | |
| const { pluginId, hostContext } = usePluginBridgeContext(); | |
| const effectiveCompanyId = options?.companyId ?? hostContext.companyId ?? undefined; | |
| const [events, setEvents] = useState<T[]>([]); | |
| const [lastEvent, setLastEvent] = useState<T | null>(null); | |
| const [connecting, setConnecting] = useState<boolean>(Boolean(effectiveCompanyId)); | |
| const [connected, setConnected] = useState(false); | |
| const [error, setError] = useState<Error | null>(null); | |
| const sourceRef = useRef<EventSource | null>(null); | |
| const close = useCallback(() => { | |
| sourceRef.current?.close(); | |
| sourceRef.current = null; | |
| setConnecting(false); | |
| setConnected(false); | |
| }, []); | |
| useEffect(() => { | |
| setEvents([]); | |
| setLastEvent(null); | |
| setError(null); | |
| if (!effectiveCompanyId) { | |
| close(); | |
| return; | |
| } | |
| const params = new URLSearchParams({ companyId: effectiveCompanyId }); | |
| const source = new EventSource( | |
| `/api/plugins/${encodeURIComponent(pluginId)}/bridge/stream/${encodeURIComponent(channel)}?${params.toString()}`, | |
| { withCredentials: true }, | |
| ); | |
| sourceRef.current = source; | |
| setConnecting(true); | |
| setConnected(false); | |
| source.onopen = () => { | |
| setConnecting(false); | |
| setConnected(true); | |
| setError(null); | |
| }; | |
| source.onmessage = (event) => { | |
| try { | |
| const parsed = JSON.parse(event.data) as T; | |
| setEvents((current) => [...current, parsed]); | |
| setLastEvent(parsed); | |
| } catch (nextError) { | |
| setError(nextError instanceof Error ? nextError : new Error(String(nextError))); | |
| } | |
| }; | |
| source.addEventListener("close", () => { | |
| source.close(); | |
| if (sourceRef.current === source) { | |
| sourceRef.current = null; | |
| } | |
| setConnecting(false); | |
| setConnected(false); | |
| }); | |
| source.onerror = () => { | |
| setConnecting(false); | |
| setConnected(false); | |
| setError(new Error(`Failed to connect to plugin stream "${channel}"`)); | |
| source.close(); | |
| if (sourceRef.current === source) { | |
| sourceRef.current = null; | |
| } | |
| }; | |
| return () => { | |
| source.close(); | |
| if (sourceRef.current === source) { | |
| sourceRef.current = null; | |
| } | |
| }; | |
| }, [channel, close, effectiveCompanyId, pluginId]); | |
| return { events, lastEvent, connecting, connected, error, close }; | |
| } | |