| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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"; |
|
|
| |
| |
| |
|
|
| |
| |
| |
| export interface PluginBridgeError { |
| code: PluginBridgeErrorCode; |
| message: string; |
| details?: unknown; |
| } |
|
|
| |
| |
| |
|
|
| 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; |
|
|
| |
| |
| |
|
|
| 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; |
| } |
|
|
| |
| |
| |
|
|
| export type PluginBridgeContextValue = { |
| pluginId: string; |
| hostContext: PluginHostContext; |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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, |
| }; |
| } |
| |
| if (typeof body.error === "string") { |
| return { |
| code: "UNKNOWN", |
| message: body.error, |
| }; |
| } |
| } |
|
|
| return { |
| code: "UNKNOWN", |
| message: err instanceof Error ? err.message : String(err), |
| }; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| 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) : ""; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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); |
|
|
| |
| 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); |
| }; |
| |
| }, [pluginId, key, paramsKey, refreshCounter, companyId, renderEnvironmentKey]); |
|
|
| const refresh = useCallback(() => { |
| setRefreshCounter((c) => c + 1); |
| }, []); |
|
|
| return { data, loading, error, refresh }; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| export type PluginActionFn = (params?: Record<string, unknown>) => Promise<unknown>; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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], |
| ); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| export function useHostContext(): PluginHostContext { |
| const { hostContext } = usePluginBridgeContext(); |
| return hostContext; |
| } |
|
|
| |
| |
| |
|
|
| export function usePluginToast(): PluginToastFn { |
| const { pushToast } = useToast(); |
| return useCallback( |
| (input: PluginToastInput) => pushToast(input), |
| [pushToast], |
| ); |
| } |
|
|
| |
| |
| |
|
|
| 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 }; |
| } |
|
|