import { computed, onMounted, ref, shallowRef, watch, type ComputedRef, type Ref, } from 'vue' import { AuthenticationRequiredError, authenticationRequiredError, useApi, } from './useApi' import { usePlatformContext } from './usePlatformContext' import { useRequestSyncState } from './useRequestSyncState' import { useAuthStore } from '../stores/auth' import { PLUGIN_SECRET_FORMAT_KEY } from '../utils/pluginSecrets' import { currentExperimentFromContext, getInjectedPlatformContext, resolveCurrentExperimentId, } from './platformContextHelpers' import { buildPluginEndpointUrl, hasKeys, pluginEndpointRequestParts, requestBodyFromPayload, } from './pluginEndpointBuilder' import { useEventStream, type EventStreamMessage, type EventStreamTransport, type UseEventStreamReturn, } from './useEventStream' import type { ExperimentSummary, GeneratedJobDefinition, PageSelectorItem, SettingsModalSchema, TopBarSettingsConfig, } from '../types' export { buildPluginEndpointUrl, resolvePluginBaseUrl, } from './pluginEndpointBuilder' export type PluginHttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' export interface PluginEndpointContract { name: string operationId?: string method: PluginHttpMethod path: string requiresAuth?: boolean pathParams?: PluginEndpointParamContract[] queryParams?: PluginEndpointParamContract[] requestType?: string | null requestContentType?: string | null responseType?: string | null responseContentType?: string | null eventStream?: boolean } export interface PluginEndpointParamContract { name: string fieldName: string type?: string required?: boolean } export interface PluginNavItemContract { path: string label: string id?: string icon?: string description?: string requiresAuth?: boolean requiresAdmin?: boolean requiresFeature?: string } export interface PluginContractSettings { modelName: string | null /** Pydantic JSON Schema used to generate typed settings values. */ valueSchema: Record | null /** SDK SettingsModal schema used to render the settings form. */ formSchema: SettingsModalSchema | null } export interface PluginContract { schemaVersion: 2 plugin: { name?: string packageName?: string version?: string description?: string routesPrefix: string apiPrefix: string type: 'analysis' | 'experiment-design' | string analysisType?: string icon?: string color?: string navItems?: PluginNavItemContract[] analysisResultReaders?: string[] analysisResultWriters?: string[] capabilities?: Record } settings: PluginContractSettings endpoints: PluginEndpointContract[] schemas?: Record> jobs?: GeneratedJobDefinition[] hash: string } export interface PluginEndpointDefinition { method: PluginHttpMethod path: string requiresAuth?: boolean pathParams?: PluginEndpointParamDefinition[] queryParams?: PluginEndpointParamDefinition[] hasBody?: boolean requestContentType?: string | null eventStream?: boolean responseType?: string | null responseContentType?: string | null } export type PluginEndpointParamDefinition = string | { name: string fieldName?: string type?: string required?: boolean } export interface CreatePluginClientOptions { endpoints: Record baseUrl?: string } export interface BuildPluginEndpointUrlOptions { /** Override the generated or platform-injected API base URL. */ baseUrl?: string /** Return only the endpoint path and query string instead of base URL + path. */ includeBaseUrl?: boolean } export interface PluginEndpointRequestOptions { /** Override the generated or platform-injected API base URL. */ baseUrl?: string /** Cancel the request without changing its endpoint or authentication behavior. */ signal?: AbortSignal } export interface PluginClientRequestOptions { /** Cancel the generated endpoint request. */ signal?: AbortSignal } export interface DownloadPluginEndpointOptions extends PluginEndpointRequestOptions { /** Browser download filename. When omitted, the blob is returned without triggering a download. */ filename?: string /** Delay before revoking the object URL after a browser download is triggered. */ revokeObjectUrlDelayMs?: number } export interface DownloadBlobOptions { /** Delay before revoking the object URL after a browser download is triggered. */ revokeObjectUrlDelayMs?: number } export type PluginSettingsValues = Partial & Record export interface UsePluginSettingsOptions { /** Plugin name for platform persistence. Defaults to the integrated platform plugin name. */ pluginName?: string /** Plugin API base URL for standalone/local settings routes. Generated clients pass pluginApiPrefix. */ apiBaseUrl?: string /** Platform API base URL for /plugins/{name}/config. Defaults to the injected platform API URL. */ platformApiBaseUrl?: string /** SettingsModal schema generated from the backend plugin config model. */ schema?: SettingsModalSchema | null /** SettingsModal title when using settingsConfig. */ title?: string /** Whether SettingsModal should include SDK appearance controls. */ showAppearance?: boolean /** Load persisted values on component mount. */ loadOnMount?: boolean } export interface UsePluginSettingsReturn> { /** Current typed settings values. */ settings: ComputedRef> /** Editable settings values for SettingsModal. */ values: Ref> /** Alias for values, kept for callers that think in platform config terms. */ config: Ref> /** Ready-to-pass AppTopBar/PluginWorkspaceView settingsConfig. */ settingsConfig: ComputedRef isLoading: Ref isSaving: Ref error: Ref lastLoadedAt: Ref lastSavedAt: Ref isDirty: ComputedRef setValues: (values: Record) => void load: () => Promise save: (values?: Record) => Promise reset: () => void } export type PluginEventStreamMessage = EventStreamMessage export interface PluginEventStreamOptions { /** Override the generated or platform-injected API base URL. */ baseUrl?: string /** Start the stream immediately. Defaults to true. */ immediate?: boolean /** Reconnect after errors or server closes. Defaults to true. */ reconnect?: boolean /** Delay before reconnecting after a stream failure or close. Defaults to 2000ms. */ reconnectDelayMs?: number /** Parse `data:` as JSON. Defaults to true for typed endpoints and false otherwise. */ parseJson?: boolean /** Fetch credentials mode. Defaults to same-origin. */ credentials?: RequestCredentials /** Extra request headers. Authorization is injected unless already supplied. */ headers?: Record onOpen?: () => void onMessage?: (message: PluginEventStreamMessage) => void onError?: (error: unknown) => void } export type UsePluginEventStreamReturn = UseEventStreamReturn export type PluginFormDataValue = | string | number | boolean | Blob | null | undefined | Record export type PluginFormDataPayload = Record export interface UseCurrentExperimentOptions { apiBaseUrl?: string immediate?: boolean } export interface UseCurrentExperimentReturn { /** Current experiment id resolved from platform injection or URL conventions. */ experimentId: ComputedRef /** Whether a current experiment id is available. */ hasExperiment: ComputedRef /** Current experiment payload, if injected or successfully fetched. */ experiment: Ref /** Whether the current experiment is currently loading. */ isLoading: Ref /** Error message from the last failed current-experiment fetch, or null. */ error: Ref /** Timestamp of the last successful current-experiment fetch, or null. */ lastLoadedAt: Ref /** Return the current experiment id or throw a clear SDK error when absent. */ requireExperimentId: () => number /** Fetch a specific experiment id, defaulting to the current experiment id. */ fetch: (experimentId?: number) => Promise /** Refetch the currently resolved experiment id. */ refresh: () => Promise } export type PluginEndpointCaller = ( payloadOrOptions?: unknown, options?: PluginClientRequestOptions, ) => Promise export type GeneratedPluginClient = Record Promise> function queryConfig(query: Record) { return hasKeys(query) ? { params: query } : undefined } const OCTET_STREAM = 'application/octet-stream' const MULTIPART_FORM_DATA = 'multipart/form-data' function endpointRequestConfig( endpoint: PluginEndpointDefinition, query: Record, options?: PluginClientRequestOptions, ) { const config: Record = {} if (hasKeys(query)) config.params = query if (endpoint.requestContentType === OCTET_STREAM) { config.headers = { 'Content-Type': OCTET_STREAM } } if (endpoint.requestContentType === MULTIPART_FORM_DATA) { config.headers = { 'Content-Type': undefined } } if (endpoint.responseContentType === OCTET_STREAM) { config.responseType = 'blob' } if (options?.signal) config.signal = options.signal return hasKeys(config) ? config : undefined } function isFormData(value: unknown): value is FormData { return typeof FormData !== 'undefined' && value instanceof FormData } function endpointRequestBody(endpoint: PluginEndpointDefinition, body: unknown): unknown { return endpoint.requestContentType === MULTIPART_FORM_DATA ? pluginFormDataFromPayload(body) : body } function endpointHasPayload(endpoint: PluginEndpointDefinition): boolean { return Boolean( endpoint.hasBody || endpoint.pathParams?.length || endpoint.queryParams?.length, ) } function isBlob(value: unknown): value is Blob { return typeof Blob !== 'undefined' && value instanceof Blob } function appendFormDataValue(formData: FormData, key: string, value: PluginFormDataValue | PluginFormDataValue[]): void { if (value === undefined || value === null) return if (Array.isArray(value)) { for (const item of value) { appendFormDataValue(formData, key, item) } return } if (isBlob(value)) { formData.append(key, value) return } if (typeof value === 'object') { formData.append(key, JSON.stringify(value)) return } formData.append(key, String(value)) } /** Convert plain upload payloads into FormData using the same encoding across generated plugin clients. */ export function pluginFormDataFromPayload(payload: unknown): FormData { if (isFormData(payload)) return payload if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { throw new Error('[MINT SDK] Plugin upload endpoints require a FormData or plain object payload.') } const formData = new FormData() for (const [key, value] of Object.entries(payload as PluginFormDataPayload)) { appendFormDataValue(formData, key, value) } return formData } /** Trigger a browser download for an already-created Blob. */ export function downloadBlob(blob: Blob, filename: string, options: DownloadBlobOptions = {}): void { if (typeof document === 'undefined' || typeof URL === 'undefined') { throw new Error('[MINT SDK] Cannot trigger a file download outside a browser.') } const blobUrl = URL.createObjectURL(blob) const link = document.createElement('a') link.href = blobUrl link.download = filename document.body.appendChild(link) link.click() document.body.removeChild(link) setTimeout(() => URL.revokeObjectURL(blobUrl), options.revokeObjectUrlDelayMs ?? 100) } function normalizePluginNavPath(path: string): string { const raw = path.trim() || '/' const prefixed = raw.startsWith('/') ? raw : `/${raw}` return prefixed.replace(/\/+$/, '') || '/' } function pluginPageIdFromPath(path: string, fallbackIndex: number): string { const normalizedPath = normalizePluginNavPath(path) if (normalizedPath === '/') return 'dashboard' return normalizedPath.replace(/^\/+/, '').replace(/\/+/g, '-') || `page-${fallbackIndex + 1}` } /** Convert resolved @mint_plugin nav_items into page selectors for the topbar and homepage PluginCards. */ export function getPluginPageSelectorItems(contract: PluginContract): PageSelectorItem[] { const pluginIcon = contract.plugin.icon ?? '' const navItems = contract.plugin.navItems ?? [] return navItems.map((item, index) => ({ id: item.id || pluginPageIdFromPath(item.path, index), label: item.label, to: normalizePluginNavPath(item.path), icon: item.icon || pluginIcon || undefined, })) } /** Create a typed plugin API client from a generated MINT plugin contract. */ export function createPluginClient( contract: PluginContract, options: CreatePluginClientOptions, ): TClient { const client: Record = {} for (const [name, endpoint] of Object.entries(options.endpoints)) { client[name] = async ( payloadOrOptions?: unknown, explicitOptions?: PluginClientRequestOptions, ) => { const hasPayload = endpointHasPayload(endpoint) const payload = hasPayload ? payloadOrOptions : undefined const callOptions = hasPayload ? explicitOptions : (payloadOrOptions as PluginClientRequestOptions | undefined) ?? explicitOptions const parts = pluginEndpointRequestParts(contract, endpoint, payload, options.baseUrl) const api = useApi({ baseUrl: parts.baseUrl, withAuth: endpoint.requiresAuth !== false, typedErrors: true, }) const config = endpointRequestConfig(endpoint, parts.query, callOptions) if (endpoint.method === 'get') { return api.get(parts.path, config) } if (endpoint.method === 'delete') { const rawBody = requestBodyFromPayload(payload, parts.requestPayload, endpoint) const body = rawBody === undefined ? undefined : endpointRequestBody(endpoint, rawBody) const config = body === undefined ? endpointRequestConfig(endpoint, parts.query, callOptions) : { ...endpointRequestConfig(endpoint, parts.query, callOptions), data: body } return api.delete(parts.path, config) } const body = endpointRequestBody( endpoint, requestBodyFromPayload(payload, parts.requestPayload, endpoint), ) if (endpoint.method === 'post') return api.post(parts.path, body, config) if (endpoint.method === 'put') return api.put(parts.path, body, config) if (endpoint.method === 'patch') return api.patch(parts.path, body, config) throw new Error(`[MINT SDK] Unsupported plugin endpoint method: ${endpoint.method}`) } } return client as TClient } /** Send multipart or raw binary data to a generated endpoint with contract URL, cancellation, and auth handling. */ export async function uploadPluginEndpoint( contract: PluginContract, endpoint: PluginEndpointDefinition, payload?: unknown, options: PluginEndpointRequestOptions = {}, ): Promise { if (!['post', 'put', 'patch'].includes(endpoint.method)) { throw new Error(`[MINT SDK] Plugin upload endpoints must use POST, PUT, or PATCH; got ${endpoint.method}`) } const parts = pluginEndpointRequestParts(contract, endpoint, payload, options.baseUrl) const api = useApi({ baseUrl: parts.baseUrl, withAuth: endpoint.requiresAuth !== false, typedErrors: true, }) const body = endpoint.hasBody ? requestBodyFromPayload(payload, parts.requestPayload, endpoint) : payload if (endpoint.requestContentType === OCTET_STREAM) { const config = endpointRequestConfig(endpoint, parts.query, { signal: options.signal }) if (endpoint.method === 'post') return api.post(parts.path, body, config) if (endpoint.method === 'put') return api.put(parts.path, body, config) return api.patch(parts.path, body, config) } const formData = pluginFormDataFromPayload(body) const config = { ...(queryConfig(parts.query) ?? {}), ...(options.signal ? { signal: options.signal } : {}), // Let Axios/browser set multipart boundaries. headers: { 'Content-Type': undefined }, } if (endpoint.method === 'post') return api.post(parts.path, formData, config) if (endpoint.method === 'put') return api.put(parts.path, formData, config) return api.patch(parts.path, formData, config) } /** Fetch a generated plugin endpoint as a Blob and optionally trigger a browser download. */ export async function downloadPluginEndpoint( contract: PluginContract, endpoint: PluginEndpointDefinition, payload?: unknown, options: DownloadPluginEndpointOptions = {}, ): Promise { if (endpoint.requestContentType === MULTIPART_FORM_DATA) { throw new Error('[MINT SDK] Plugin download endpoints do not support multipart request bodies') } const parts = pluginEndpointRequestParts(contract, endpoint, payload, options.baseUrl) const api = useApi({ baseUrl: parts.baseUrl, withAuth: endpoint.requiresAuth !== false, typedErrors: true, }) const config = { ...(endpointRequestConfig(endpoint, parts.query, { signal: options.signal }) ?? {}), responseType: 'blob' as const, } const rawBody = requestBodyFromPayload(payload, parts.requestPayload, endpoint) const body = rawBody === undefined ? undefined : endpointRequestBody(endpoint, rawBody) let blob: Blob if (endpoint.method === 'get') blob = await api.get(parts.path, config) else if (endpoint.method === 'post') blob = await api.post(parts.path, body, config) else if (endpoint.method === 'put') blob = await api.put(parts.path, body, config) else if (endpoint.method === 'patch') blob = await api.patch(parts.path, body, config) else if (endpoint.method === 'delete') { blob = await api.delete(parts.path, body === undefined ? config : { ...config, data: body }) } else { throw new Error(`[MINT SDK] Unsupported plugin endpoint method: ${endpoint.method}`) } if (options.filename) { downloadBlob(blob, options.filename, { revokeObjectUrlDelayMs: options.revokeObjectUrlDelayMs, }) } return blob } function normalizePluginSettingsOptions(options: UsePluginSettingsOptions | string = {}): UsePluginSettingsOptions { return typeof options === 'string' ? { pluginName: options } : options } /** @deprecated Removed in MINT 1.4; use `useGeneratedPluginClient()` for generated clients. */ export function usePluginClient(client: TClient): TClient { return client } const PLATFORM_OWNED_SETTINGS_KEY = 'allowed_experiment_types' /** * Secret settings travel as `{ "$secret": "" }` references, so an untouched * secret round-trips without ever exposing its value. Declaring the format on * every write keeps a `null` unambiguous: it clears the stored secret instead of * meaning "unchanged", which is what the deprecated placeholder form meant. */ function withSecretFormat( payload: Record, secretFormat: string | null, ): Record { if (!secretFormat) return payload return { ...payload, [PLUGIN_SECRET_FORMAT_KEY]: secretFormat } } function responseSecretFormat(response: unknown): string | null { if (!response || typeof response !== 'object') return null const format = (response as Record)[PLUGIN_SECRET_FORMAT_KEY] return typeof format === 'string' && format ? format : null } function cloneRecord(value: Record): Record { return JSON.parse(JSON.stringify(value)) as Record } function hasOwnSetting(settings: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(settings, key) } function responseSettingsPayload(response: unknown): Record { if (!response || typeof response !== 'object') return {} const data = response as { config?: unknown settings?: unknown } if (data.config && typeof data.config === 'object') { return cloneRecord(data.config as Record) } if (data.settings && typeof data.settings === 'object') { return cloneRecord(data.settings as Record) } return {} } function responseSettingsRevision(response: unknown): string | null { if (!response || typeof response !== 'object') return null const revision = (response as { revision?: unknown }).revision return typeof revision === 'string' && revision ? revision : null } function topLevelSettingsPatch( current: Record, keys: Iterable, ): Record { return Object.fromEntries( [...keys].map(key => [ key, hasOwnSetting(current, key) ? current[key] : null, ]), ) } function changedTopLevelSettingKeys( current: Record, saved: Record, ): Set { return new Set( [...new Set([...Object.keys(current), ...Object.keys(saved)])].filter( key => JSON.stringify(current[key]) !== JSON.stringify(saved[key]), ), ) } function platformOwnedSettingsPatch(settings: Record): Record { return { [PLATFORM_OWNED_SETTINGS_KEY]: hasOwnSetting(settings, PLATFORM_OWNED_SETTINGS_KEY) ? settings[PLATFORM_OWNED_SETTINGS_KEY] : null, } } /** Load, edit, and persist plugin settings from platform config or the plugin-local settings router. */ export function usePluginSettings>( options: UsePluginSettingsOptions | string = {}, ): UsePluginSettingsReturn { const resolvedOptions = normalizePluginSettingsOptions(options) const injectedContext = getInjectedPlatformContext() const { plugin, isIntegrated } = usePlatformContext() const platformApi = useApi({ baseUrl: resolvedOptions.platformApiBaseUrl ?? injectedContext?.platformApiUrl }) const pluginApi = useApi({ baseUrl: resolvedOptions.apiBaseUrl ?? injectedContext?.plugin?.api_prefix }) const values = ref({}) as Ref> const savedValues = shallowRef>({}) const revision = ref(null) const secretFormat = ref(null) const request = useRequestSyncState('Plugin settings request failed.') const isLoading = ref(false) const isSaving = ref(false) const resolvedPluginName = computed(() => resolvedOptions.pluginName ?? plugin.value?.name ?? injectedContext?.plugin?.name ?? '', ) const usesPluginSettingsApi = computed(() => { const currentPlugin = injectedContext?.plugin ?? plugin.value return currentPlugin?.settings_api === 'plugin' && resolvedPluginName.value === currentPlugin.name }) const usesPlatformConfig = computed(() => Boolean( (isIntegrated.value || injectedContext?.isIntegrated) && resolvedPluginName.value && !usesPluginSettingsApi.value, ), ) const settings = computed(() => values.value) const isDirty = computed(() => JSON.stringify(values.value) !== JSON.stringify(savedValues.value)) const settingsConfig = computed(() => ({ title: resolvedOptions.title, schema: resolvedOptions.schema ?? undefined, showAppearance: resolvedOptions.showAppearance, values: values.value, })) function setValues(nextValues: Record): void { values.value = cloneRecord(nextValues) as PluginSettingsValues } function applyLoaded( nextValues: Record, nextRevision: string | null, nextSecretFormat: string | null = secretFormat.value, ): void { const cloned = cloneRecord(nextValues) values.value = cloned as PluginSettingsValues savedValues.value = cloneRecord(cloned) revision.value = nextRevision secretFormat.value = nextSecretFormat } async function load(): Promise { isLoading.value = true try { const response = usesPlatformConfig.value ? await request.run( () => platformApi.get<{ plugin_name: string; config: Record }>( `/plugins/${encodeURIComponent(resolvedPluginName.value)}/config`, ), { success: 'load', errorMessage: 'Failed to load plugin settings' }, ) : await request.run( () => pluginApi.get<{ settings: Record; mode?: string }>('/settings'), { success: 'load', errorMessage: 'Failed to load plugin settings' }, ) applyLoaded( responseSettingsPayload(response), responseSettingsRevision(response), responseSecretFormat(response), ) } catch { // Error state is handled by request.run(). } finally { isLoading.value = false } } async function save(nextValues?: Record): Promise { if (nextValues) { setValues(nextValues) } isSaving.value = true try { const payload = cloneRecord(values.value) let response: unknown if (usesPlatformConfig.value) { const dirtyKeys = changedTopLevelSettingKeys(payload, savedValues.value) const platformOwnedChanged = dirtyKeys.has(PLATFORM_OWNED_SETTINGS_KEY) const typedKeys = [...dirtyKeys].filter( key => key !== PLATFORM_OWNED_SETTINGS_KEY, ) let nextRevision = revision.value if (typedKeys.length) { response = await request.run( () => platformApi.patch<{ plugin_name: string config: Record revision?: string }>( `/plugins/${encodeURIComponent(resolvedPluginName.value)}/config`, { config: withSecretFormat( topLevelSettingsPatch(payload, typedKeys), secretFormat.value, ), }, ), { success: 'save', errorMessage: 'Failed to save plugin settings' }, ) nextRevision = responseSettingsRevision(response) ?? nextRevision } if (platformOwnedChanged) { response = await request.run( () => platformApi.patch<{ plugin_name: string config: Record revision?: string }>( `/plugins/${encodeURIComponent(resolvedPluginName.value)}/config`, { config: platformOwnedSettingsPatch(payload) }, ), { success: 'save', errorMessage: 'Failed to save plugin settings' }, ) nextRevision = responseSettingsRevision(response) ?? nextRevision } if (!response) return true applyLoaded( responseSettingsPayload(response), nextRevision, responseSecretFormat(response) ?? secretFormat.value, ) return true } else { const ifMatch = revision.value ? { headers: { 'If-Match': `"${revision.value}"` } } : undefined response = await request.run( () => pluginApi.put<{ settings: Record mode?: string revision?: string }>('/settings', withSecretFormat(payload, secretFormat.value), ifMatch), { success: 'save', errorMessage: 'Failed to save plugin settings' }, ) } applyLoaded( responseSettingsPayload(response), responseSettingsRevision(response) ?? revision.value, responseSecretFormat(response) ?? secretFormat.value, ) return true } catch { return false } finally { isSaving.value = false } } function reset(): void { values.value = cloneRecord(savedValues.value) as PluginSettingsValues request.clearError() } onMounted(() => { if (resolvedOptions.loadOnMount !== false) { void load() } }) return { settings, values, config: values, settingsConfig, isLoading, isSaving, error: request.error, lastLoadedAt: request.lastLoadedAt, lastSavedAt: request.lastSavedAt, isDirty, setValues, load, save, reset, } } function streamRequestAuth( requestHeaders: HeadersInit | undefined, policy: { requiresAuth: boolean }, ): { headers: Headers token?: string } { const headers = new Headers(requestHeaders) const authStore = useAuthStore() if (!authStore.isInitialized) { authStore.initialize() } if (policy.requiresAuth && authStore.token && !headers.has('Authorization')) { headers.set('Authorization', `Bearer ${authStore.token}`) } const token = policy.requiresAuth ? authStore.token : undefined return { headers, token: token && headers.get('Authorization') === `Bearer ${token}` ? token : undefined, } } function pluginEventStreamTransport( endpoint: PluginEndpointDefinition, ): EventStreamTransport { return async (url, init = {}) => { if (typeof fetch !== 'function') { throw new Error('[MINT SDK] fetch is required for plugin event streams.') } const requestAuth = streamRequestAuth( init.headers, { requiresAuth: endpoint.requiresAuth !== false }, ) const response = await fetch(url, { ...init, headers: requestAuth.headers }) if (response.status === 401) { throw authenticationRequiredError(response, requestAuth.token) } return response } } function looksLikeEventStreamOptions(value: unknown): value is PluginEventStreamOptions { if (!value || typeof value !== 'object') return false const candidate = value as Record return [ 'baseUrl', 'immediate', 'reconnect', 'reconnectDelayMs', 'parseJson', 'credentials', 'headers', 'onOpen', 'onMessage', 'onError', ].some(key => key in candidate) } /** Consume a generated plugin SSE endpoint with auth headers, reconnect, and teardown. */ export function usePluginEventStream( contract: PluginContract, endpoint: PluginEndpointDefinition, options?: PluginEventStreamOptions, ): UsePluginEventStreamReturn export function usePluginEventStream( contract: PluginContract, endpoint: PluginEndpointDefinition, payload?: unknown, options?: PluginEventStreamOptions, ): UsePluginEventStreamReturn export function usePluginEventStream( contract: PluginContract, endpoint: PluginEndpointDefinition, payloadOrOptions?: unknown, maybeOptions?: PluginEventStreamOptions, ): UsePluginEventStreamReturn { if (endpoint.method !== 'get') { throw new Error(`[MINT SDK] Plugin event streams must use GET; got ${endpoint.method}`) } const payload = maybeOptions === undefined && looksLikeEventStreamOptions(payloadOrOptions) ? undefined : payloadOrOptions const options = ( maybeOptions === undefined && looksLikeEventStreamOptions(payloadOrOptions) ? payloadOrOptions : maybeOptions ?? {} ) as PluginEventStreamOptions const responseType = endpoint.responseType?.trim() const parseJson = options.parseJson ?? Boolean(responseType && responseType !== 'string') return useEventStream( () => buildPluginEndpointUrl(contract, endpoint, payload, { baseUrl: options.baseUrl }), { transport: pluginEventStreamTransport(endpoint), immediate: options.immediate, reconnect: options.reconnect, reconnectDelayMs: options.reconnectDelayMs, parseJson, credentials: options.credentials, headers: options.headers, shouldReconnect: cause => !(cause instanceof AuthenticationRequiredError), onOpen: options.onOpen, onMessage: options.onMessage, onError: options.onError, errorLabel: 'Plugin event stream', }, ) } /** Read and optionally load the current platform experiment for integrated plugin views. */ export function useCurrentExperiment( options: UseCurrentExperimentOptions = {}, ): UseCurrentExperimentReturn { const api = useApi({ baseUrl: options.apiBaseUrl ?? getInjectedPlatformContext()?.platformApiUrl }) const experiment = shallowRef(currentExperimentFromContext()) const request = useRequestSyncState('Failed to load current experiment') const isLoading = request.loading const error = request.error const lastLoadedAt = request.lastLoadedAt const experimentId = computed(() => { return resolveCurrentExperimentId() }) const hasExperiment = computed(() => experimentId.value !== undefined) function requireExperimentId(): number { const id = experimentId.value if (id === undefined) { throw new Error('[MINT SDK] No current experiment is selected.') } return id } async function fetchExperiment(id = experimentId.value): Promise { if (id === undefined) { experiment.value = currentExperimentFromContext() return experiment.value } try { const result = await request.run( () => api.get(`/experiments/${id}`), { success: 'load', errorMessage: 'Failed to load current experiment' }, ) experiment.value = result return result } catch (e) { experiment.value = undefined return undefined } } async function refresh(): Promise { return fetchExperiment() } watch( experimentId, (id) => { if (options.immediate === false) return if (id === undefined) { experiment.value = currentExperimentFromContext() return } void fetchExperiment(id) }, { immediate: options.immediate !== false }, ) return { experimentId, hasExperiment, experiment, isLoading, error, lastLoadedAt, requireExperimentId, fetch: fetchExperiment, refresh, } }