/** * 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 { 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; 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; 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(null); function usePluginBridgeContext(): PluginBridgeContextValue { const ctx = useContext(PluginBridgeContext); if (!ctx) { throw new Error( "Plugin bridge hook called outside of a . " + "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; 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 { 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(key, params)`. * * Makes an HTTP POST to `/api/plugins/:pluginId/data/:key` and returns * a reactive `PluginDataResult` matching the SDK type contract. * * Re-fetches automatically when `key` or `params` change. Provides a * `refresh()` function for manual re-fetch. */ export function usePluginData( key: string, params?: Record, ): PluginDataResult { const { pluginId, hostContext } = usePluginBridgeContext(); const companyId = hostContext.companyId; const renderEnvironmentSnapshot = serializeRenderEnvironment(hostContext.renderEnvironment); const renderEnvironmentKey = serializeRenderEnvironmentSnapshot(renderEnvironmentSnapshot); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [refreshCounter, setRefreshCounter] = useState(0); // Stable serialization for params change detection const paramsKey = serializeParams(params); useEffect(() => { let cancelled = false; let retryTimer: ReturnType | 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) => Promise; /** * 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): Promise => { 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 { events: T[]; lastEvent: T | null; connecting: boolean; connected: boolean; error: Error | null; close(): void; } export function usePluginStream( channel: string, options?: { companyId?: string }, ): PluginStreamResult { const { pluginId, hostContext } = usePluginBridgeContext(); const effectiveCompanyId = options?.companyId ?? hostContext.companyId ?? undefined; const [events, setEvents] = useState([]); const [lastEvent, setLastEvent] = useState(null); const [connecting, setConnecting] = useState(Boolean(effectiveCompanyId)); const [connected, setConnected] = useState(false); const [error, setError] = useState(null); const sourceRef = useRef(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 }; }