// --------------------------------------------------------------------------- // integrations.sh catalog client — the connect dialog's long-tail search. // // The curated presets stay the featured list; this module reaches the rest of // the public registry. Search stays server-side (`/api/search`, edge-cached) // so the client never downloads the full multi-thousand-entry catalog, and the // per-domain surface document is fetched only when the user picks a result, // to resolve the URL the add form needs (MCP endpoint, OpenAPI spec URL, or // GraphQL endpoint). // --------------------------------------------------------------------------- import { useEffect, useRef, useState } from "react"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { getDomain } from "tldts"; import type { IntegrationPlugin } from "@executor-js/sdk/client"; export const INTEGRATIONS_SH_ORIGIN = "https://integrations.sh"; /** Registry kinds executor can connect (the registry also lists CLIs). Kind * strings deliberately match the plugin keys. */ export const CONNECTABLE_KINDS = ["mcp", "openapi", "graphql"] as const; export type CatalogKind = (typeof CONNECTABLE_KINDS)[number]; const isConnectableKind = (kind: string): kind is CatalogKind => (CONNECTABLE_KINDS as readonly string[]).includes(kind); export interface CatalogSearchEntry { readonly domain: string; readonly description: string; readonly kinds: readonly CatalogKind[]; } export const catalogLogoUrl = (domain: string, size: number): string => `${INTEGRATIONS_SH_ORIGIN}/logo/${domain}?sz=${size * 2}`; class CatalogRequestError extends Data.TaggedError("CatalogRequestError")<{ readonly message: string; }> {} const fetchCatalogJson = (url: URL): Effect.Effect => Effect.gen(function* () { const response = yield* Effect.tryPromise({ try: () => fetch(url), catch: () => new CatalogRequestError({ message: `Failed to reach ${url.host}.` }), }); if (!response.ok) { return yield* new CatalogRequestError({ message: `Unexpected status ${response.status} from ${url.host}.`, }); } return yield* Effect.tryPromise({ try: () => response.json() as Promise, catch: () => new CatalogRequestError({ message: "Response was not valid JSON." }), }); }); // --------------------------------------------------------------------------- // Search // --------------------------------------------------------------------------- const SearchResponse = Schema.Struct({ results: Schema.Array( Schema.Struct({ domain: Schema.String, description: Schema.String, kinds: Schema.Array(Schema.String), }), ), }); const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse); export const parseCatalogSearch = (payload: unknown): readonly CatalogSearchEntry[] => Option.match(decodeSearchResponse(payload), { onNone: () => [], onSome: ({ results }) => results .map((entry) => ({ domain: entry.domain, description: entry.description, kinds: entry.kinds.filter(isConnectableKind), })) .filter((entry) => entry.kinds.length > 0), }); export const searchCatalog = ( query: string, limit = 10, ): Effect.Effect => { const url = new URL("/api/search", INTEGRATIONS_SH_ORIGIN); url.searchParams.set("q", query); url.searchParams.set("limit", String(limit)); return Effect.map(fetchCatalogJson(url), parseCatalogSearch); }; // --------------------------------------------------------------------------- // Connect-target resolution (per-domain surface document) // --------------------------------------------------------------------------- const SurfaceDocument = Schema.Struct({ surfaces: Schema.Array( Schema.Struct({ type: Schema.String, slug: Schema.optional(Schema.String), url: Schema.optional(Schema.String), spec: Schema.optional(Schema.String), }), ), }); const decodeSurfaceDocument = Schema.decodeUnknownOption(SurfaceDocument); type SurfaceRecord = (typeof SurfaceDocument.Type)["surfaces"][number]; export interface CatalogConnectTarget { readonly kind: CatalogKind; /** What the add form's URL field expects: the MCP endpoint, the OpenAPI * spec URL, or the GraphQL endpoint. */ readonly url: string; /** The registry's stable slug for the surface — seeds the namespace field. */ readonly slug?: string; } // The surface document's `type` vocabulary: OpenAPI surfaces are `http` (spec // present ⇒ machine-readable), and `spec` on a GraphQL surface is an SDL // pointer or the literal "introspection" — the endpoint is what executor adds. const connectUrlOf = (surface: SurfaceRecord, kind: CatalogKind): string | undefined => kind === "mcp" && surface.type === "mcp" ? surface.url : kind === "openapi" && surface.type === "http" ? surface.spec : kind === "graphql" && surface.type === "graphql" ? surface.url : undefined; export const pickConnectTarget = ( payload: unknown, kind: CatalogKind, ): CatalogConnectTarget | undefined => Option.match(decodeSurfaceDocument(payload), { onNone: () => undefined, onSome: ({ surfaces }) => { for (const surface of surfaces) { const url = connectUrlOf(surface, kind); if (url) return { kind, url, ...(surface.slug ? { slug: surface.slug } : {}) }; } return undefined; }, }); /** The connect target for the first of `kinds` (caller's preference order) * that has a usable locator in the domain's surface document. */ export const resolveConnectTarget = ( domain: string, kinds: readonly CatalogKind[], ): Effect.Effect => { const url = new URL(`/api/${encodeURIComponent(domain)}/surface`, INTEGRATIONS_SH_ORIGIN); return Effect.map(fetchCatalogJson(url), (payload) => { for (const kind of kinds) { const target = pickConnectTarget(payload, kind); if (target) return target; } return undefined; }); }; // --------------------------------------------------------------------------- // Filtering against the curated presets // --------------------------------------------------------------------------- /** Domains already represented by a loaded plugin's presets, so the catalog * section doesn't repeat what the preset list above it already shows. */ export const presetDomains = (plugins: readonly IntegrationPlugin[]): ReadonlySet => { const domains = new Set(); for (const plugin of plugins) { for (const preset of plugin.presets ?? []) { for (const candidate of [preset.icon, preset.url]) { if (!candidate) continue; const logoMatch = /^https:\/\/integrations\.sh\/logo\/([^/?]+)/.exec(candidate); const domain = logoMatch?.[1] ?? getDomain(candidate); if (domain) domains.add(domain); } } } return domains; }; /** Kinds this deployment can actually add — the plugin key vocabulary matches * the catalog kind vocabulary. */ export const availableCatalogKinds = ( plugins: readonly IntegrationPlugin[], ): readonly CatalogKind[] => CONNECTABLE_KINDS.filter((kind) => plugins.some((plugin) => plugin.key === kind)); export const filterCatalogEntries = ( entries: readonly CatalogSearchEntry[], opts: { readonly excludeDomains: ReadonlySet; readonly availableKinds: readonly CatalogKind[]; }, ): readonly CatalogSearchEntry[] => entries .filter((entry) => !opts.excludeDomains.has(entry.domain)) .map((entry) => ({ ...entry, kinds: entry.kinds.filter((kind) => opts.availableKinds.includes(kind)), })) .filter((entry) => entry.kinds.length > 0); // --------------------------------------------------------------------------- // Hook — debounced search with an in-session response cache // --------------------------------------------------------------------------- const SEARCH_DEBOUNCE_MS = 250; const MIN_QUERY_LENGTH = 2; const searchCache = new Map(); export interface CatalogSearchState { readonly entries: readonly CatalogSearchEntry[]; readonly loading: boolean; } export function useCatalogSearch(rawQuery: string): CatalogSearchState { const query = rawQuery.trim().toLowerCase(); const [state, setState] = useState({ entries: [], loading: false }); const generation = useRef(0); useEffect(() => { const requestId = ++generation.current; if (query.length < MIN_QUERY_LENGTH) { setState({ entries: [], loading: false }); return; } const cached = searchCache.get(query); if (cached) { setState({ entries: cached, loading: false }); return; } setState((previous) => ({ ...previous, loading: true })); const timer = setTimeout(() => { void Effect.runPromiseExit(searchCatalog(query)).then((exit) => { if (Exit.isSuccess(exit)) searchCache.set(query, exit.value); if (generation.current !== requestId) return; // Reachability is best-effort — the presets and URL detection above // the catalog section keep working without it. setState({ entries: Exit.isSuccess(exit) ? exit.value : [], loading: false }); }); }, SEARCH_DEBOUNCE_MS); return () => clearTimeout(timer); }, [query]); return state; }