From cb286c7f4da6a8c9207f2220a9d5af6c9fb72ffe Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 10 Jun 2026 12:09:49 +0545 Subject: [PATCH 1/3] feat: standardize plugin operation invoke API Plugin operation calls are moving to Mission Control's /invoke/ endpoint, while some HTTP-style operations still need the proxy path during the transition. Default invoke calls now use POST /invoke/:operation and callers can opt into /proxy/:operation with { proxy: true }. The second argument is used as the operation body for body-capable methods or query params for GET and HEAD. --- README.md | 38 +++++++++----- example/src/Logs.tsx | 15 +++--- src/index.ts | 121 +++++++++++++++++++++++++++++-------------- test/index.test.ts | 54 ++++++++++++++----- 4 files changed, 154 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index d004232..ea1346c 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ For code running inside a Mission Control plugin UI: import { invoke, ready, stream } from "@flanksource/plugin-ui-sdk"; ``` -### `invoke(operation, body?, options?)` +### `invoke(operation, bodyOrQueryParams?, options?)` Calls a plugin backend operation and returns the native `Response`. @@ -34,12 +34,21 @@ if (!res.ok) throw new Error(await res.text()); const rows = await res.json(); ``` -With query parameters: +With parameters: ```ts -const res = await invoke("logs", undefined, { +const res = await invoke("logs", { + tail: 100, + container: "api", +}); +``` + +Use the proxy endpoint for HTTP-style operations: + +```ts +const res = await invoke("logs", { tail: 100, container: "api" }, { method: "GET", - query: { tail: 100, container: "api" }, + proxy: true, }); ``` @@ -47,20 +56,22 @@ Behavior: - Uses the current plugin UI URL to find the installed plugin name. - Automatically includes the current `config_id`. -- Defaults to `POST` when `body` is provided. -- Defaults to `GET` when no `body` is provided. +- Defaults to `POST /api/plugins/:pluginRef/invoke/:operation`. +- Set `options.proxy: true` to use `/proxy/:operation` instead. +- Sends `{}` when no body is provided for methods that support a body. +- For `GET`/`HEAD`, treats the second argument as query parameters. - JSON-encodes non-`BodyInit` bodies and sets `content-type: application/json`. The current URL convention is: ```text /api/plugins/:pluginRef/ui?config_id=:configId -/api/plugins/:pluginRef/proxy/:operation?config_id=:configId +/api/plugins/:pluginRef/invoke/:operation?config_id=:configId ``` ### `stream(operation, query?, options?)` -Opens an SSE stream to a plugin operation. +Opens an SSE stream to a plugin operation. Streaming continues to use Mission Control's `/proxy/` endpoint because `EventSource` uses GET. ```ts const events = stream("tail-logs", { pod: "api-123", tail: 100 }); @@ -97,8 +108,8 @@ const runtime = createPluginRuntime({ basePath: "/api/plugins", }); -const res = await runtime.invoke("list-pods", undefined, { - query: { namespace: "default" }, +const res = await runtime.invoke("list-pods", { + namespace: "default", }); ``` @@ -145,8 +156,9 @@ const plugin = await mc.plugins.get("kubernetes-logs"); ```ts const res = await mc.plugins.invoke("kubernetes-logs", "list-pods", { + namespace: "default", +}, { configId: "config-123", - query: { namespace: "default" }, }); const pods = await res.json(); @@ -167,7 +179,7 @@ Important exported types: ```ts type ConnectionMode = "pass-through" | "proxy"; -type QueryParams = Record; +type QueryParams = Record; // arrays also supported interface MissionControlClient { mode: ConnectionMode; @@ -179,7 +191,7 @@ interface MissionControlClient { interface PluginRegistryApi { list(): Promise; get(pluginRef: string): Promise; - invoke(pluginRef: string, operation: string, request?: PluginInvokeRequest): Promise; + invoke(pluginRef: string, operation: string, bodyOrQueryParams?: unknown, options?: PluginInvokeOptions): Promise; stream(pluginRef: string, operation: string, request?: PluginStreamRequest): EventSource; } ``` diff --git a/example/src/Logs.tsx b/example/src/Logs.tsx index a076abf..4c694dc 100644 --- a/example/src/Logs.tsx +++ b/example/src/Logs.tsx @@ -33,7 +33,7 @@ export function Logs() { setEvents([]) try { - const res = await plugins.invoke(pluginRef, 'list-pods', { + const res = await plugins.invoke(pluginRef, 'list-pods', undefined, { configId: configId || undefined, }) const text = await res.text() @@ -59,14 +59,13 @@ export function Logs() { try { const res = await plugins.invoke(pluginRef, 'logs', { + namespace, + pod: pod || undefined, + container: container || undefined, + tailLines: Number(tail) || undefined, + follow: false, + }, { configId: configId || undefined, - query: { - namespace, - pod: pod || undefined, - container: container || undefined, - tailLines: Number(tail) || undefined, - follow: false, - }, }) const text = await res.text() diff --git a/src/index.ts b/src/index.ts index 4f0ea92..e1d3fb5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ export type QueryValue = string | number | boolean | null | undefined; export type QueryParams = Record; export type InvokeOptions = Omit & { - query?: QueryParams; + proxy?: boolean; }; export type StreamOptions = EventSourceInit; @@ -24,8 +24,8 @@ export type PluginRuntime = { pluginRef: string; configId?: string; basePath: string; - operationURL(operation: string, query?: QueryParams): string; - invoke(operation: string, body?: unknown, options?: InvokeOptions): Promise; + operationURL(operation: string, bodyOrQueryParams?: unknown, options?: InvokeOptions): string; + invoke(operation: string, bodyOrQueryParams?: unknown, options?: InvokeOptions): Promise; stream(operation: string, query?: QueryParams, options?: StreamOptions): EventSource; }; @@ -46,7 +46,7 @@ export type MissionControlClient = { export type PluginRegistryApi = { list(): Promise; get(pluginRef: string): Promise; - invoke(pluginRef: string, operation: string, request?: PluginInvokeRequest): Promise; + invoke(pluginRef: string, operation: string, bodyOrQueryParams?: unknown, options?: PluginInvokeOptions): Promise; stream(pluginRef: string, operation: string, request?: PluginStreamRequest): EventSource; }; @@ -73,11 +73,8 @@ export type PluginComponent = { operation?: string; }; -export type PluginInvokeRequest = { +export type PluginInvokeOptions = InvokeOptions & { configId?: string; - body?: unknown; - query?: QueryParams; - init?: Omit; }; export type PluginStreamRequest = { @@ -131,31 +128,51 @@ export function createPluginRuntime(context: PluginRuntimeContext): PluginRuntim const fetchImpl = context.fetch; const EventSourceImpl = context.EventSource; - const operationURL = (operation: string, query?: QueryParams): string => - pluginOperationURL({ - basePath, - pluginRef, - operation, - configId, - query, - }); + const operationURL = ( + operation: string, + bodyOrQueryParams?: unknown, + options: InvokeOptions = {}, + ): string => + pluginOperationURL( + { + basePath, + pluginRef, + operation, + configId, + query: queryForMethod(options.method, bodyOrQueryParams), + }, + options.proxy ? "proxy" : "invoke", + ); return { pluginRef, configId, basePath, operationURL, - invoke(operation: string, body?: unknown, options: InvokeOptions = {}): Promise { + invoke( + operation: string, + bodyOrQueryParams?: unknown, + options: InvokeOptions = {}, + ): Promise { return invokeURL( fetchImpl ?? globalFetch(), - operationURL(operation, options.query), - body, + operationURL(operation, bodyOrQueryParams, options), + bodyOrQueryParams, options, ); }, stream(operation: string, query?: QueryParams, options?: StreamOptions): EventSource { const EventSourceCtor = EventSourceImpl ?? globalEventSource(); - return new EventSourceCtor(operationURL(operation, query), options); + return new EventSourceCtor( + pluginProxyOperationURL({ + basePath, + pluginRef, + operation, + configId, + query, + }), + options, + ); }, }; } @@ -199,15 +216,16 @@ export function createMissionControlClient(options: MissionControlClientOptions) invoke( pluginRef: string, operation: string, - pluginRequest: PluginInvokeRequest = {}, + bodyOrQueryParams?: unknown, + options: PluginInvokeOptions = {}, ): Promise { - return runtimeFor(pluginRef, pluginRequest.configId).invoke( + const { configId, ...invokeOptions } = options; + return runtimeFor(pluginRef, configId).invoke( operation, - pluginRequest.body, + bodyOrQueryParams, { - ...pluginRequest.init, - query: pluginRequest.query, - credentials: pluginRequest.init?.credentials ?? defaultCredentials, + ...invokeOptions, + credentials: invokeOptions.credentials ?? defaultCredentials, }, ); }, @@ -251,16 +269,12 @@ function invokeURL( body?: unknown, options: InvokeOptions = {}, ): Promise { - const { query: _query, ...requestInit } = options; - const hasBody = body !== undefined; - const method = requestMethod(requestInit.method, hasBody); - - if (hasBody && isBodylessMethod(method)) { - throw sdkError(`${method} requests cannot include a body; use query params instead`); - } - + const { proxy: _proxy, method: configuredMethod, ...requestInit } = options; + const method = requestMethod(configuredMethod); + const bodyless = isBodylessMethod(method); + const payload = body === undefined ? {} : body; const headers = new Headers(requestInit.headers); - const encodedBody = hasBody ? encodeBody(body, headers) : undefined; + const encodedBody = bodyless ? undefined : encodeBody(payload, headers); return fetchImpl(url, { ...requestInit, @@ -293,16 +307,29 @@ function runtimeContext(win: Window): WindowRuntimeContext { }; } -function pluginOperationURL(args: { +function pluginProxyOperationURL(args: { basePath: string; pluginRef: string; operation: string; configId?: string; query?: QueryParams; }): string { + return pluginOperationURL(args, "proxy"); +} + +function pluginOperationURL( + args: { + basePath: string; + pluginRef: string; + operation: string; + configId?: string; + query?: QueryParams; + }, + endpoint: "invoke" | "proxy", +): string { const operation = validateOperation(args.operation); const url = new URL( - `${args.basePath}/${encodeURIComponent(args.pluginRef)}/proxy/${encodeURIComponent(operation)}`, + `${args.basePath}/${encodeURIComponent(args.pluginRef)}/${endpoint}/${encodeURIComponent(operation)}`, fallbackBaseURL(), ); @@ -363,14 +390,30 @@ function isBodyInit(value: unknown): value is BodyInit { ); } -function requestMethod(method: string | undefined, hasBody: boolean): string { - return (method ?? (hasBody ? "POST" : "GET")).toUpperCase(); +function requestMethod(method: string | undefined): string { + return (method ?? "POST").toUpperCase(); } function isBodylessMethod(method: string): boolean { return method === "GET" || method === "HEAD"; } +function queryForMethod( + method: string | undefined, + bodyOrQueryParams: unknown, +): QueryParams | undefined { + if (!isBodylessMethod(requestMethod(method))) return undefined; + if (bodyOrQueryParams === undefined || bodyOrQueryParams === null) return undefined; + if (isPlainQueryParams(bodyOrQueryParams)) return bodyOrQueryParams; + throw sdkError("GET and HEAD requests require query params as a plain object"); +} + +function isPlainQueryParams(value: unknown): value is QueryParams { + if (!value || typeof value !== "object") return false; + if (isBodyInit(value)) return false; + return Object.getPrototypeOf(value) === Object.prototype; +} + function credentialsForMode(mode: ConnectionMode): RequestCredentials { return mode === "pass-through" ? "include" : "same-origin"; } diff --git a/test/index.test.ts b/test/index.test.ts index 1e3b88b..3692693 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -44,7 +44,7 @@ describe("invoke", () => { expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock.mock.calls[0][0]).toBe( - "/api/plugins/my-postgres/proxy/explain?config_id=config-123", + "/api/plugins/my-postgres/invoke/explain?config_id=config-123", ); const init = fetchMock.mock.calls[0][1] as RequestInit; expect(init.method).toBe("POST"); @@ -53,27 +53,50 @@ describe("invoke", () => { expect(new Headers(init.headers).get("content-type")).toBe("application/json"); }); - it("defaults to GET and appends query params when body is omitted", async () => { + it("defaults to POST and sends the second argument as the invoke body", async () => { setWindow("/api/plugins/kubernetes-logs/ui/logs?config_id=abc"); const fetchMock = vi.fn(async () => new Response("{}")); vi.stubGlobal("fetch", fetchMock); - await invoke("logs", undefined, { - query: { tail: 100, container: "api", empty: null, repeated: ["a", "b"] }, + await invoke("logs", { tail: 100, container: "api", empty: null, repeated: ["a", "b"] }); + + expect(fetchMock.mock.calls[0][0]).toBe( + "/api/plugins/kubernetes-logs/invoke/logs?config_id=abc", + ); + const init = fetchMock.mock.calls[0][1] as RequestInit; + expect(init.method).toBe("POST"); + expect(init.body).toBe(JSON.stringify({ tail: 100, container: "api", empty: null, repeated: ["a", "b"] })); + }); + + it("uses the proxy endpoint and query params for GET requests", async () => { + setWindow("/api/plugins/kubernetes-logs/ui/logs?config_id=abc"); + const fetchMock = vi.fn(async () => new Response("{}")); + vi.stubGlobal("fetch", fetchMock); + + await invoke("logs", { tail: 100, container: "api", empty: null, repeated: ["a", "b"] }, { + method: "GET", + proxy: true, }); expect(fetchMock.mock.calls[0][0]).toBe( "/api/plugins/kubernetes-logs/proxy/logs?config_id=abc&tail=100&container=api&repeated=a&repeated=b", ); - expect((fetchMock.mock.calls[0][1] as RequestInit).method).toBe("GET"); + const init = fetchMock.mock.calls[0][1] as RequestInit; + expect(init.method).toBe("GET"); + expect(init.body).toBeUndefined(); }); - it("does not allow a body on GET", async () => { + it("sends an empty params object when body and query are omitted", async () => { setWindow("/api/plugins/my-postgres/ui/query?config_id=config-123"); + const fetchMock = vi.fn(async () => new Response("{}")); + vi.stubGlobal("fetch", fetchMock); - await expect(invoke("query", { sql: "select 1" }, { method: "GET" })).rejects.toThrow( - "GET requests cannot include a body", + await invoke("databases-list"); + + expect(fetchMock.mock.calls[0][0]).toBe( + "/api/plugins/my-postgres/invoke/databases-list?config_id=config-123", ); + expect((fetchMock.mock.calls[0][1] as RequestInit).body).toBe(JSON.stringify({})); }); }); @@ -116,14 +139,18 @@ describe("createPluginRuntime", () => { }); expect(runtime.operationURL("pods", { namespace: "default" })).toBe( + "/api/plugins/kubernetes-logs/invoke/pods?config_id=config-123", + ); + expect(runtime.operationURL("pods", { namespace: "default" }, { method: "GET", proxy: true })).toBe( "/api/plugins/kubernetes-logs/proxy/pods?config_id=config-123&namespace=default", ); - await runtime.invoke("pods", undefined, { query: { namespace: "default" } }); + await runtime.invoke("pods", { namespace: "default" }); expect(fetchMock.mock.calls[0][0]).toBe( - "/api/plugins/kubernetes-logs/proxy/pods?config_id=config-123&namespace=default", + "/api/plugins/kubernetes-logs/invoke/pods?config_id=config-123", ); + expect((fetchMock.mock.calls[0][1] as RequestInit).body).toBe(JSON.stringify({ namespace: "default" })); }); it("supports custom base paths for host embedding", () => { @@ -134,7 +161,7 @@ describe("createPluginRuntime", () => { }); expect(runtime.operationURL("monthly")).toBe( - "/api/mission-control/api/plugins/cost/proxy/monthly?config_id=abc", + "/api/mission-control/api/plugins/cost/invoke/monthly?config_id=abc", ); }); }); @@ -162,13 +189,12 @@ describe("createMissionControlClient", () => { fetch: fetchMock, }); - await client.plugins.invoke("kubernetes-logs", "pods", { + await client.plugins.invoke("kubernetes-logs", "pods", { namespace: "default" }, { configId: "config-123", - query: { namespace: "default" }, }); expect(fetchMock.mock.calls[0][0]).toBe( - "https://mc.example.com/api/plugins/kubernetes-logs/proxy/pods?config_id=config-123&namespace=default", + "https://mc.example.com/api/plugins/kubernetes-logs/invoke/pods?config_id=config-123", ); expect((fetchMock.mock.calls[0][1] as RequestInit).credentials).toBe("include"); }); From 817bb1a69847a17f11b6df03514d334a54471bdf Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 10 Jun 2026 12:50:04 +0545 Subject: [PATCH 2/3] refactor: scope plugin operations to instances Replace the generic Mission Control client and nested plugins API with a plugin client that creates scoped plugin instances. Plugin instances carry pluginRef and configId, exposing invoke and stream methods that automatically include config_id while preserving invoke-by-default and proxy opt-in behavior. --- README.md | 174 ++++------- example/src/Logs.tsx | 25 +- example/src/MissionControlContext.tsx | 10 +- src/index.ts | 416 +++++++------------------- test/index.test.ts | 232 +++++++------- 5 files changed, 286 insertions(+), 571 deletions(-) diff --git a/README.md b/README.md index ea1346c..5ad4169 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,6 @@ # @flanksource/plugin-ui-sdk -Browser SDK for Mission Control plugin UIs and host apps embedding Mission Control plugins. - -The SDK has two layers: - -1. **Plugin runtime API**: used inside a plugin UI to call its own backend operations. -2. **Host client API**: used by Backstage/flanksource-ui/customer apps to connect to Mission Control via pass-through cookies or backend proxy auth. +Browser SDK for calling Mission Control plugin operations. ## Install @@ -13,120 +8,93 @@ The SDK has two layers: pnpm add @flanksource/plugin-ui-sdk ``` -## Plugin runtime API +## Plugin client API -For code running inside a Mission Control plugin UI: +Create a Mission Control plugin client, then create an instance for a specific plugin/config pair: ```ts -import { invoke, ready, stream } from "@flanksource/plugin-ui-sdk"; +import { createMissionControlPluginClient } from "@flanksource/plugin-ui-sdk"; + +const pluginClient = createMissionControlPluginClient({ + mode: "proxy", + baseUrl: "/api/mission-control", +}); + +const kubernetes = pluginClient.New("kubernetes", "config-123"); ``` -### `invoke(operation, bodyOrQueryParams?, options?)` +### `pluginClient.New(pluginRef, configId?)` + +Creates a plugin instance scoped to a plugin ref and optional catalog config id. +The instance exposes the operation methods. + +### `instance.invoke(operation, bodyOrQueryParams?, options?)` -Calls a plugin backend operation and returns the native `Response`. +Calls a plugin operation and returns the native `Response`. ```ts -const res = await invoke("query", { - sql: "select * from users limit 10", -}); +const res = await kubernetes.invoke("list-pods"); if (!res.ok) throw new Error(await res.text()); const rows = await res.json(); ``` -With parameters: - -```ts -const res = await invoke("logs", { - tail: 100, - container: "api", -}); -``` - -Use the proxy endpoint for HTTP-style operations: +With params/body: ```ts -const res = await invoke("logs", { tail: 100, container: "api" }, { - method: "GET", - proxy: true, +const res = await kubernetes.invoke("create-pod", { + namespace: "default", + name: "nginx", + image: "nginx:latest", }); ``` Behavior: -- Uses the current plugin UI URL to find the installed plugin name. -- Automatically includes the current `config_id`. - Defaults to `POST /api/plugins/:pluginRef/invoke/:operation`. -- Set `options.proxy: true` to use `/proxy/:operation` instead. +- Set `options.proxy: true` to use `/api/plugins/:pluginRef/proxy/:operation` instead; `pluginRef` comes from `pluginClient.New(pluginRef, configId)` and the HTTP method comes from `options.method`. +- Sends the instance `configId` as the `config_id` query parameter. - Sends `{}` when no body is provided for methods that support a body. -- For `GET`/`HEAD`, treats the second argument as query parameters. +- For `GET`/`HEAD`, treats the second argument as query params. - JSON-encodes non-`BodyInit` bodies and sets `content-type: application/json`. -The current URL convention is: +HTTP-style proxy request: -```text -/api/plugins/:pluginRef/ui?config_id=:configId -/api/plugins/:pluginRef/invoke/:operation?config_id=:configId +```ts +const res = await kubernetes.invoke("list-pods", { + namespace: "default", + labelSelector: "app=web", +}, { + method: "GET", + proxy: true, +}); +// GET /api/plugins/kubernetes/proxy/list-pods?config_id=config-123&namespace=default&labelSelector=app%3Dweb ``` -### `stream(operation, query?, options?)` +### `instance.stream(operation, query?)` -Opens an SSE stream to a plugin operation. Streaming continues to use Mission Control's `/proxy/` endpoint because `EventSource` uses GET. +Opens an SSE stream to a plugin operation via Mission Control's `/proxy/` endpoint. ```ts -const events = stream("tail-logs", { pod: "api-123", tail: 100 }); +const logs = pluginClient.New("kubernetes-logs", "config-123"); +const events = logs.stream("tail-logs", { + pod: "api-123", + tail: 100, +}); events.onmessage = event => { console.log(event.data); }; ``` -### `ready()` - -Signals the host frame that the plugin UI has loaded. - -```ts -ready(); -``` - -Posts: - -```ts -{ type: "mc.tab.ready" } -``` - -## Explicit plugin runtime - -For native embedding or tests, create a runtime explicitly instead of deriving context from the iframe URL: - -```ts -import { createPluginRuntime } from "@flanksource/plugin-ui-sdk"; - -const runtime = createPluginRuntime({ - pluginRef: "kubernetes-logs", - configId: "config-123", - basePath: "/api/plugins", -}); - -const res = await runtime.invoke("list-pods", { - namespace: "default", -}); -``` - -## Host client API - -For apps embedding Mission Control plugin functionality: - -```ts -import { createMissionControlClient } from "@flanksource/plugin-ui-sdk"; -``` +## Connection modes ### Proxy mode Browser calls the host backend. The host backend injects service auth and proxies to Mission Control. ```ts -const mc = createMissionControlClient({ +const pluginClient = createMissionControlPluginClient({ mode: "proxy", baseUrl: "/api/mission-control", }); @@ -137,7 +105,7 @@ const mc = createMissionControlClient({ Browser calls Mission Control directly using Mission Control cookies/session. ```ts -const mc = createMissionControlClient({ +const pluginClient = createMissionControlPluginClient({ mode: "pass-through", baseUrl: "https://mission-control.example.com", }); @@ -145,54 +113,26 @@ const mc = createMissionControlClient({ Pass-through requires Mission Control cookies and CORS to support credentialed browser requests. -### Plugin discovery - -```ts -const plugins = await mc.plugins.list(); -const plugin = await mc.plugins.get("kubernetes-logs"); -``` - -### Invoke plugin operations from the host - -```ts -const res = await mc.plugins.invoke("kubernetes-logs", "list-pods", { - namespace: "default", -}, { - configId: "config-123", -}); - -const pods = await res.json(); -``` - -### Stream plugin operations from the host - -```ts -const events = mc.plugins.stream("kubernetes-logs", "tail-logs", { - configId: "config-123", - query: { pod: "api-123", container: "api" }, -}); -``` - ## Types Important exported types: ```ts type ConnectionMode = "pass-through" | "proxy"; -type QueryParams = Record; // arrays also supported +type QueryValue = string | number | boolean | null | undefined; +type QueryParams = Record; -interface MissionControlClient { +interface MissionControlPluginClient { mode: ConnectionMode; baseUrl: string; - request(path: string, init?: RequestInit): Promise; - plugins: PluginRegistryApi; + New(pluginRef: string, configId?: string): MissionControlPluginInstance; } -interface PluginRegistryApi { - list(): Promise; - get(pluginRef: string): Promise; - invoke(pluginRef: string, operation: string, bodyOrQueryParams?: unknown, options?: PluginInvokeOptions): Promise; - stream(pluginRef: string, operation: string, request?: PluginStreamRequest): EventSource; +interface MissionControlPluginInstance { + pluginRef: string; + configId?: string; + invoke(operation: string, bodyOrQueryParams?: unknown, options?: PluginInvokeOptions): Promise; + stream(operation: string, query?: QueryParams): EventSource; } ``` @@ -202,7 +142,7 @@ Build plugin UIs as relocatable static apps: - Use relative asset URLs. For Vite, set `base: "./"`. - Use hash routing for internal UI routes. -- Use `invoke()` and `stream()` for plugin backend calls instead of hardcoding `/api/plugins/...` URLs. +- Use `instance.invoke()` and `instance.stream()` for plugin backend calls instead of hardcoding `/api/plugins/...` URLs. Vite example: diff --git a/example/src/Logs.tsx b/example/src/Logs.tsx index 4c694dc..37f93e6 100644 --- a/example/src/Logs.tsx +++ b/example/src/Logs.tsx @@ -18,7 +18,7 @@ type PodRow = { } export function Logs() { - const { plugins } = useMissionControl() + const pluginClient = useMissionControl() const [configId, setConfigId] = useState(defaultConfigId) const [namespace, setNamespace] = useState('default') const [pod, setPod] = useState('') @@ -33,9 +33,7 @@ export function Logs() { setEvents([]) try { - const res = await plugins.invoke(pluginRef, 'list-pods', undefined, { - configId: configId || undefined, - }) + const res = await pluginClient.New(pluginRef, configId || undefined).invoke('list-pods') const text = await res.text() const rows = JSON.parse(text) as PodRow[] setPods(rows) @@ -58,14 +56,12 @@ export function Logs() { setEvents([]) try { - const res = await plugins.invoke(pluginRef, 'logs', { + const res = await pluginClient.New(pluginRef, configId || undefined).invoke('logs', { namespace, pod: pod || undefined, container: container || undefined, tailLines: Number(tail) || undefined, follow: false, - }, { - configId: configId || undefined, }) const text = await res.text() @@ -84,15 +80,12 @@ export function Logs() { setResult({ status: 'idle' }) try { - const source = plugins.stream(pluginRef, 'logs', { - configId: configId || undefined, - query: { - namespace, - pod: pod || undefined, - container: container || undefined, - tailLines: Number(tail) || undefined, - follow: true, - }, + const source = pluginClient.New(pluginRef, configId || undefined).stream('logs', { + namespace, + pod: pod || undefined, + container: container || undefined, + tailLines: Number(tail) || undefined, + follow: true, }) source.onmessage = event => { diff --git a/example/src/MissionControlContext.tsx b/example/src/MissionControlContext.tsx index 35bdca3..032ce11 100644 --- a/example/src/MissionControlContext.tsx +++ b/example/src/MissionControlContext.tsx @@ -1,8 +1,8 @@ import React, { createContext, useContext, useMemo } from 'react'; import { - createMissionControlClient, + createMissionControlPluginClient, type ConnectionMode, - type MissionControlClient, + type MissionControlPluginClient, } from '@flanksource/plugin-ui-sdk'; type MissionControlProviderProps = { @@ -11,11 +11,11 @@ type MissionControlProviderProps = { children: React.ReactNode; }; -const MissionControlContext = createContext(null); +const MissionControlContext = createContext(null); export function MissionControlProvider({ mode, baseUrl, children }: MissionControlProviderProps) { const client = useMemo( - () => createMissionControlClient({ mode, baseUrl }), + () => createMissionControlPluginClient({ mode, baseUrl }), [mode, baseUrl], ); @@ -26,7 +26,7 @@ export function MissionControlProvider({ mode, baseUrl, children }: MissionContr ); } -export function useMissionControl(): MissionControlClient { +export function useMissionControl(): MissionControlPluginClient { const client = useContext(MissionControlContext); if (!client) { throw new Error('useMissionControl must be used inside MissionControlProvider'); diff --git a/src/index.ts b/src/index.ts index e1d3fb5..547b8af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,52 +2,37 @@ export type QueryValue = string | number | boolean | null | undefined; export type QueryParams = Record; -export type InvokeOptions = Omit & { - proxy?: boolean; -}; - -export type StreamOptions = EventSourceInit; - export type ConnectionMode = "pass-through" | "proxy"; export type PluginComponentType = "panel" | "table" | "timeseries" | "action"; -export type PluginRuntimeContext = { - pluginRef: string; - configId?: string; - basePath?: string; - fetch?: typeof fetch; - EventSource?: typeof EventSource; -}; - -export type PluginRuntime = { - pluginRef: string; - configId?: string; - basePath: string; - operationURL(operation: string, bodyOrQueryParams?: unknown, options?: InvokeOptions): string; - invoke(operation: string, bodyOrQueryParams?: unknown, options?: InvokeOptions): Promise; - stream(operation: string, query?: QueryParams, options?: StreamOptions): EventSource; +export type PluginInvokeOptions = Omit & { + /** Use Mission Control's /proxy/:operation endpoint instead of /invoke/:operation. */ + proxy?: boolean; }; -export type MissionControlClientOptions = { +export type MissionControlPluginClientOptions = { mode: ConnectionMode; baseUrl: string; fetch?: typeof fetch; EventSource?: typeof EventSource; }; -export type MissionControlClient = { +export type MissionControlPluginClient = { mode: ConnectionMode; baseUrl: string; - request(path: string, init?: RequestInit): Promise; - plugins: PluginRegistryApi; + New(pluginRef: string, configId?: string): MissionControlPluginInstance; }; -export type PluginRegistryApi = { - list(): Promise; - get(pluginRef: string): Promise; - invoke(pluginRef: string, operation: string, bodyOrQueryParams?: unknown, options?: PluginInvokeOptions): Promise; - stream(pluginRef: string, operation: string, request?: PluginStreamRequest): EventSource; +export type MissionControlPluginInstance = { + pluginRef: string; + configId?: string; + invoke( + operation: string, + bodyOrQueryParams?: unknown, + options?: PluginInvokeOptions, + ): Promise; + stream(operation: string, query?: QueryParams): EventSource; }; export type PluginManifest = { @@ -73,249 +58,95 @@ export type PluginComponent = { operation?: string; }; -export type PluginInvokeOptions = InvokeOptions & { - configId?: string; -}; - -export type PluginStreamRequest = { - configId?: string; - query?: QueryParams; - init?: EventSourceInit; -}; - const DEFAULT_PLUGIN_BASE_PATH = "/api/plugins"; const FALLBACK_BASE_URL = "http://plugin-ui-sdk.local"; const CONFIG_ID_QUERY_PARAM = "config_id"; -const PLUGIN_UI_PATH_PATTERN = /^\/api\/plugins\/([^/]+)\/ui(?:\/|$)/; -const READY_MESSAGE = { type: "mc.tab.ready" } as const; - -/** - * Calls a backend operation for the current plugin UI. - * - * This preserves the original iframe/runtime behavior: context is derived from - * /api/plugins//ui?config_id=. - */ -export async function invoke( - operation: string, - body?: unknown, - options: InvokeOptions = {}, -): Promise { - return runtimeFromWindow().invoke(operation, body, options); -} -/** Opens an SSE stream for the current plugin UI. */ -export function stream( - operation: string, - query?: QueryParams, - options?: StreamOptions, -): EventSource { - return runtimeFromWindow().stream(operation, query, options); -} - -/** Signals the host frame that the plugin UI is ready. */ -export function ready(): void { - currentWindow().parent.postMessage(READY_MESSAGE, "*"); -} - -/** - * Creates an explicit plugin runtime. Useful for native embedding, tests, and - * host apps that do not run the plugin UI at /api/plugins//ui. - */ -export function createPluginRuntime(context: PluginRuntimeContext): PluginRuntime { - const pluginRef = requireNonBlank(context.pluginRef, "pluginRef"); - const configId = normalizeOptionalString(context.configId); - const basePath = normalizeBasePath(context.basePath ?? DEFAULT_PLUGIN_BASE_PATH); - const fetchImpl = context.fetch; - const EventSourceImpl = context.EventSource; - - const operationURL = ( - operation: string, - bodyOrQueryParams?: unknown, - options: InvokeOptions = {}, - ): string => - pluginOperationURL( - { - basePath, - pluginRef, - operation, - configId, - query: queryForMethod(options.method, bodyOrQueryParams), - }, - options.proxy ? "proxy" : "invoke", - ); - - return { - pluginRef, - configId, - basePath, - operationURL, - invoke( - operation: string, - bodyOrQueryParams?: unknown, - options: InvokeOptions = {}, - ): Promise { - return invokeURL( - fetchImpl ?? globalFetch(), - operationURL(operation, bodyOrQueryParams, options), - bodyOrQueryParams, - options, - ); - }, - stream(operation: string, query?: QueryParams, options?: StreamOptions): EventSource { - const EventSourceCtor = EventSourceImpl ?? globalEventSource(); - return new EventSourceCtor( - pluginProxyOperationURL({ - basePath, - pluginRef, - operation, - configId, - query, - }), - options, - ); - }, - }; -} - -/** Creates a host-side Mission Control client for proxy or pass-through mode. */ -export function createMissionControlClient(options: MissionControlClientOptions): MissionControlClient { +/** Creates a Mission Control plugin client. */ +export function createMissionControlPluginClient( + options: MissionControlPluginClientOptions, +): MissionControlPluginClient { const mode = options.mode; const baseUrl = normalizeBaseUrl(options.baseUrl); - const fetchImpl = options.fetch ?? globalFetch(); + const fetchImpl = options.fetch; const EventSourceImpl = options.EventSource; const defaultCredentials = credentialsForMode(mode); const pluginBasePath = joinURL(baseUrl, DEFAULT_PLUGIN_BASE_PATH); - const request = (path: string, init: RequestInit = {}): Promise => - fetchImpl(joinURL(baseUrl, path), withDefaultCredentials(init, defaultCredentials)); - - const runtimeFor = (pluginRef: string, configId?: string): PluginRuntime => - createPluginRuntime({ - pluginRef, - configId, - basePath: pluginBasePath, - fetch: fetchImpl, - EventSource: EventSourceImpl, - }); - - const plugins: PluginRegistryApi = { - async list(): Promise { - return responseJSON( - await request(DEFAULT_PLUGIN_BASE_PATH), - "failed to list plugins", - ); - }, - - async get(pluginRef: string): Promise { - return responseJSON( - await request(`${DEFAULT_PLUGIN_BASE_PATH}/${encodeURIComponent(pluginRef)}`), - `failed to get plugin ${pluginRef}`, - ); - }, - - invoke( - pluginRef: string, - operation: string, - bodyOrQueryParams?: unknown, - options: PluginInvokeOptions = {}, - ): Promise { - const { configId, ...invokeOptions } = options; - return runtimeFor(pluginRef, configId).invoke( - operation, - bodyOrQueryParams, - { - ...invokeOptions, - credentials: invokeOptions.credentials ?? defaultCredentials, - }, - ); - }, - - stream( - pluginRef: string, - operation: string, - pluginRequest: PluginStreamRequest = {}, - ): EventSource { - return runtimeFor(pluginRef, pluginRequest.configId).stream( - operation, - pluginRequest.query, - { - ...pluginRequest.init, - withCredentials: pluginRequest.init?.withCredentials ?? mode === "pass-through", - }, - ); - }, - }; - return { mode, baseUrl, - request, - plugins, - }; -} -function runtimeFromWindow(): PluginRuntime { - const { pluginRef, configId } = runtimeContext(currentWindow()); - return createPluginRuntime({ - pluginRef, - configId, - basePath: DEFAULT_PLUGIN_BASE_PATH, - }); -} - -function invokeURL( - fetchImpl: typeof fetch, - url: string, - body?: unknown, - options: InvokeOptions = {}, -): Promise { - const { proxy: _proxy, method: configuredMethod, ...requestInit } = options; - const method = requestMethod(configuredMethod); - const bodyless = isBodylessMethod(method); - const payload = body === undefined ? {} : body; - const headers = new Headers(requestInit.headers); - const encodedBody = bodyless ? undefined : encodeBody(payload, headers); - - return fetchImpl(url, { - ...requestInit, - method, - credentials: requestInit.credentials ?? "same-origin", - headers, - body: encodedBody, - }); -} - -type WindowRuntimeContext = { - pluginRef: string; - configId: string; -}; - -function runtimeContext(win: Window): WindowRuntimeContext { - const match = PLUGIN_UI_PATH_PATTERN.exec(win.location.pathname); - if (!match) { - throw sdkError("expected to run under /api/plugins//ui"); - } - - const configId = new URLSearchParams(win.location.search).get(CONFIG_ID_QUERY_PARAM); - if (!configId) { - throw sdkError("missing config_id in plugin UI URL"); - } + New(pluginRef: string, configId?: string): MissionControlPluginInstance { + const normalizedPluginRef = requirePathSegment(pluginRef, "pluginRef"); + const normalizedConfigId = normalizeOptionalString(configId); + + return { + pluginRef: normalizedPluginRef, + configId: normalizedConfigId, + + invoke( + operation: string, + bodyOrQueryParams?: unknown, + invokeOptions: PluginInvokeOptions = {}, + ): Promise { + const { proxy, method: configuredMethod, ...requestInit } = invokeOptions; + const method = requestMethod(configuredMethod); + const bodyless = isBodylessMethod(method); + const query = bodyless ? requireQueryParams(bodyOrQueryParams) : undefined; + const url = pluginOperationURL( + { + basePath: pluginBasePath, + pluginRef: normalizedPluginRef, + operation, + configId: normalizedConfigId, + query, + }, + proxy ? "proxy" : "invoke", + ); + const headers = new Headers(requestInit.headers); + const encodedBody = bodyless + ? undefined + : encodeBody(bodyOrQueryParams === undefined ? {} : bodyOrQueryParams, headers); + + return (fetchImpl ?? globalFetch())(url, { + ...requestInit, + method, + credentials: requestInit.credentials ?? defaultCredentials, + headers, + body: encodedBody, + }); + }, - return { - pluginRef: decodeURIComponent(match[1]), - configId, + stream(operation: string, query?: QueryParams): EventSource { + const url = pluginOperationURL( + { + basePath: pluginBasePath, + pluginRef: normalizedPluginRef, + operation, + configId: normalizedConfigId, + query, + }, + "proxy", + ); + + const EventSourceCtor = EventSourceImpl ?? globalEventSource(); + return new EventSourceCtor(url, { withCredentials: mode === "pass-through" }); + }, + }; + }, }; } -function pluginProxyOperationURL(args: { - basePath: string; - pluginRef: string; - operation: string; - configId?: string; - query?: QueryParams; -}): string { - return pluginOperationURL(args, "proxy"); -} +/** + * Naming alias for createMissionControlPluginClient. + * + * This is not fully backwards-compatible with the old generic Mission Control + * client shape: it does not expose request() or a nested plugins API. Migrate + * mc.plugins.invoke(...) to client.New(pluginRef, configId).invoke(...) and + * mc.plugins.stream(...) to client.New(pluginRef, configId).stream(...). + */ +export const createMissionControlClient = createMissionControlPluginClient; function pluginOperationURL( args: { @@ -327,9 +158,10 @@ function pluginOperationURL( }, endpoint: "invoke" | "proxy", ): string { - const operation = validateOperation(args.operation); + const pluginRef = requirePathSegment(args.pluginRef, "pluginRef"); + const operation = requirePathSegment(args.operation, "operation"); const url = new URL( - `${args.basePath}/${encodeURIComponent(args.pluginRef)}/${endpoint}/${encodeURIComponent(operation)}`, + `${args.basePath}/${encodeURIComponent(pluginRef)}/${endpoint}/${encodeURIComponent(operation)}`, fallbackBaseURL(), ); @@ -339,27 +171,30 @@ function pluginOperationURL( return stripFallbackOrigin(url); } -function validateOperation(operation: string): string { - const normalized = requireNonBlank(operation, "operation"); - if (normalized.includes("/")) { - throw sdkError("operation must be a single path segment"); - } - return normalized; -} - function appendQuery(searchParams: URLSearchParams, query?: QueryParams): void { if (!query) return; for (const [key, value] of Object.entries(query)) { - if (key === CONFIG_ID_QUERY_PARAM) continue; - + const queryKey = key === "configId" ? CONFIG_ID_QUERY_PARAM : key; for (const item of queryValues(value)) { if (item === null || item === undefined) continue; - searchParams.append(key, String(item)); + searchParams.append(queryKey, String(item)); } } } +function requireQueryParams(value: unknown): QueryParams | undefined { + if (value === undefined || value === null) return undefined; + if (isPlainQueryParams(value)) return value; + throw sdkError("GET and HEAD requests require query params as a plain object"); +} + +function isPlainQueryParams(value: unknown): value is QueryParams { + if (!value || typeof value !== "object") return false; + if (isBodyInit(value)) return false; + return Object.getPrototypeOf(value) === Object.prototype; +} + function queryValues(value: QueryValue | readonly QueryValue[]): readonly QueryValue[] { return isQueryValueArray(value) ? value : [value]; } @@ -398,48 +233,10 @@ function isBodylessMethod(method: string): boolean { return method === "GET" || method === "HEAD"; } -function queryForMethod( - method: string | undefined, - bodyOrQueryParams: unknown, -): QueryParams | undefined { - if (!isBodylessMethod(requestMethod(method))) return undefined; - if (bodyOrQueryParams === undefined || bodyOrQueryParams === null) return undefined; - if (isPlainQueryParams(bodyOrQueryParams)) return bodyOrQueryParams; - throw sdkError("GET and HEAD requests require query params as a plain object"); -} - -function isPlainQueryParams(value: unknown): value is QueryParams { - if (!value || typeof value !== "object") return false; - if (isBodyInit(value)) return false; - return Object.getPrototypeOf(value) === Object.prototype; -} - function credentialsForMode(mode: ConnectionMode): RequestCredentials { return mode === "pass-through" ? "include" : "same-origin"; } -function withDefaultCredentials(init: RequestInit, credentials: RequestCredentials): RequestInit { - return { - ...init, - credentials: init.credentials ?? credentials, - }; -} - -async function responseJSON(response: Response, message: string): Promise { - if (!response.ok) { - throw sdkError(`${message}: ${response.status} ${response.statusText}`.trim()); - } - - return response.json() as Promise; -} - -function currentWindow(): Window { - if (typeof window === "undefined") { - throw sdkError("window is not available in this environment"); - } - return window; -} - function globalFetch(): typeof fetch { if (typeof fetch === "undefined") { throw sdkError("fetch is not available in this environment"); @@ -454,10 +251,6 @@ function globalEventSource(): typeof EventSource { return EventSource; } -function normalizeBasePath(basePath: string): string { - return requireNonBlank(trimTrailingSlash(basePath), "basePath"); -} - function normalizeBaseUrl(baseUrl: string): string { const trimmed = baseUrl.trim(); if (!trimmed) throw sdkError("baseUrl is required"); @@ -469,9 +262,10 @@ function normalizeOptionalString(value: string | undefined): string | undefined return trimmed || undefined; } -function requireNonBlank(value: string, field: string): string { +function requirePathSegment(value: string, field: string): string { const trimmed = value.trim(); if (!trimmed) throw sdkError(`${field} is required`); + if (trimmed.includes("/")) throw sdkError(`${field} must be a single path segment`); return trimmed; } diff --git a/test/index.test.ts b/test/index.test.ts index 3692693..5c6588b 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2,31 +2,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createMissionControlClient, - createPluginRuntime, - invoke, - ready, - stream, + createMissionControlPluginClient, } from "../src/index.js"; -type WindowStub = { - location: URL; - parent: { postMessage: ReturnType }; -}; - const originalWindow = globalThis.window; const originalFetch = globalThis.fetch; const originalEventSource = globalThis.EventSource; -function setWindow(path: string): WindowStub { - const win = { - location: new URL(`https://mc.example.com${path}`), - parent: { postMessage: vi.fn() }, - } as unknown as WindowStub; - - vi.stubGlobal("window", win); - return win; -} - afterEach(() => { vi.unstubAllGlobals(); if (originalWindow !== undefined) vi.stubGlobal("window", originalWindow); @@ -34,168 +16,174 @@ afterEach(() => { if (originalEventSource !== undefined) vi.stubGlobal("EventSource", originalEventSource); }); -describe("invoke", () => { - it("derives the installed plugin name and config_id from the iframe URL", async () => { - setWindow("/api/plugins/my-postgres/ui/query?config_id=config-123"); +describe("createMissionControlPluginClient", () => { + it("creates plugin instances scoped to pluginRef and configId", () => { + const client = createMissionControlPluginClient({ + mode: "proxy", + baseUrl: "/api/mission-control", + fetch: vi.fn(), + }); + + const plugin = client.New("kubernetes", " config-123 "); + + expect(plugin.pluginRef).toBe("kubernetes"); + expect(plugin.configId).toBe("config-123"); + }); + + it("invokes plugin operations through /invoke by default", async () => { const fetchMock = vi.fn(async () => new Response("{}")); - vi.stubGlobal("fetch", fetchMock); + const client = createMissionControlPluginClient({ + mode: "proxy", + baseUrl: "/api/mission-control", + fetch: fetchMock, + }); + const plugin = client.New("kubernetes", "config-123"); - await invoke("explain", { sql: "select 1" }); + await plugin.invoke("create-pod", { + namespace: "default", + name: "nginx", + image: "nginx:latest", + }); expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock.mock.calls[0][0]).toBe( - "/api/plugins/my-postgres/invoke/explain?config_id=config-123", + "/api/mission-control/api/plugins/kubernetes/invoke/create-pod?config_id=config-123", ); const init = fetchMock.mock.calls[0][1] as RequestInit; expect(init.method).toBe("POST"); expect(init.credentials).toBe("same-origin"); - expect(init.body).toBe(JSON.stringify({ sql: "select 1" })); + expect(init.body).toBe(JSON.stringify({ + namespace: "default", + name: "nginx", + image: "nginx:latest", + })); expect(new Headers(init.headers).get("content-type")).toBe("application/json"); }); - it("defaults to POST and sends the second argument as the invoke body", async () => { - setWindow("/api/plugins/kubernetes-logs/ui/logs?config_id=abc"); + it("sends an empty params object when body is omitted", async () => { const fetchMock = vi.fn(async () => new Response("{}")); - vi.stubGlobal("fetch", fetchMock); + const client = createMissionControlPluginClient({ + mode: "proxy", + baseUrl: "/", + fetch: fetchMock, + }); - await invoke("logs", { tail: 100, container: "api", empty: null, repeated: ["a", "b"] }); + await client.New("kubernetes").invoke("list-pods"); - expect(fetchMock.mock.calls[0][0]).toBe( - "/api/plugins/kubernetes-logs/invoke/logs?config_id=abc", + expect(fetchMock.mock.calls[0][0]).toBe("/api/plugins/kubernetes/invoke/list-pods"); + expect((fetchMock.mock.calls[0][1] as RequestInit).body).toBe(JSON.stringify({})); + }); + + it("requires baseUrl", () => { + expect(() => createMissionControlPluginClient({ mode: "proxy", baseUrl: "" })).toThrow( + "baseUrl is required", ); - const init = fetchMock.mock.calls[0][1] as RequestInit; - expect(init.method).toBe("POST"); - expect(init.body).toBe(JSON.stringify({ tail: 100, container: "api", empty: null, repeated: ["a", "b"] })); }); - it("uses the proxy endpoint and query params for GET requests", async () => { - setWindow("/api/plugins/kubernetes-logs/ui/logs?config_id=abc"); + it("uses /proxy and query params for GET requests", async () => { const fetchMock = vi.fn(async () => new Response("{}")); - vi.stubGlobal("fetch", fetchMock); - - await invoke("logs", { tail: 100, container: "api", empty: null, repeated: ["a", "b"] }, { + const client = createMissionControlPluginClient({ + mode: "proxy", + baseUrl: "/api/mission-control", + fetch: fetchMock, + }); + const plugin = client.New("kubernetes", "config-123"); + + await plugin.invoke("list-pods", { + namespace: "default", + labelSelector: "app=web", + empty: null, + repeated: ["a", "b"], + }, { method: "GET", proxy: true, }); expect(fetchMock.mock.calls[0][0]).toBe( - "/api/plugins/kubernetes-logs/proxy/logs?config_id=abc&tail=100&container=api&repeated=a&repeated=b", + "/api/mission-control/api/plugins/kubernetes/proxy/list-pods?config_id=config-123&namespace=default&labelSelector=app%3Dweb&repeated=a&repeated=b", ); const init = fetchMock.mock.calls[0][1] as RequestInit; expect(init.method).toBe("GET"); expect(init.body).toBeUndefined(); }); - it("sends an empty params object when body and query are omitted", async () => { - setWindow("/api/plugins/my-postgres/ui/query?config_id=config-123"); + it("uses /proxy for body-capable methods when proxy is true", async () => { const fetchMock = vi.fn(async () => new Response("{}")); - vi.stubGlobal("fetch", fetchMock); - - await invoke("databases-list"); - - expect(fetchMock.mock.calls[0][0]).toBe( - "/api/plugins/my-postgres/invoke/databases-list?config_id=config-123", - ); - expect((fetchMock.mock.calls[0][1] as RequestInit).body).toBe(JSON.stringify({})); - }); -}); - -describe("stream", () => { - it("opens an EventSource to the operation URL", () => { - setWindow("/api/plugins/kubernetes-logs/ui/logs?config_id=abc"); - - const eventSourceMock = vi.fn(function EventSourceStub(this: { url: string; options?: EventSourceInit }, url: string, options?: EventSourceInit) { - this.url = url; - this.options = options; + const client = createMissionControlPluginClient({ + mode: "proxy", + baseUrl: "/api/mission-control", + fetch: fetchMock, }); - vi.stubGlobal("EventSource", eventSourceMock); + const plugin = client.New("kubernetes", "config-123"); - stream("tail", { pod: "api" }, { withCredentials: true }); + await plugin.invoke("create-pod", { + namespace: "default", + name: "nginx", + image: "nginx:latest", + }, { proxy: true }); - expect(eventSourceMock).toHaveBeenCalledWith( - "/api/plugins/kubernetes-logs/proxy/tail?config_id=abc&pod=api", - { withCredentials: true }, + expect(fetchMock.mock.calls[0][0]).toBe( + "/api/mission-control/api/plugins/kubernetes/proxy/create-pod?config_id=config-123", ); + expect((fetchMock.mock.calls[0][1] as RequestInit).method).toBe("POST"); + expect((fetchMock.mock.calls[0][1] as RequestInit).body).toBe(JSON.stringify({ + namespace: "default", + name: "nginx", + image: "nginx:latest", + })); }); -}); - -describe("ready", () => { - it("posts the ready message to the parent frame", () => { - const win = setWindow("/api/plugins/demo/ui?config_id=abc"); - ready(); - - expect(win.parent.postMessage).toHaveBeenCalledWith({ type: "mc.tab.ready" }, "*"); - }); -}); - -describe("createPluginRuntime", () => { - it("creates operation URLs without relying on window", async () => { + it("uses include credentials for pass-through mode", async () => { const fetchMock = vi.fn(async () => new Response("{}")); - const runtime = createPluginRuntime({ - pluginRef: "kubernetes-logs", - configId: "config-123", + const client = createMissionControlPluginClient({ + mode: "pass-through", + baseUrl: "https://mc.example.com", fetch: fetchMock, }); - expect(runtime.operationURL("pods", { namespace: "default" })).toBe( - "/api/plugins/kubernetes-logs/invoke/pods?config_id=config-123", - ); - expect(runtime.operationURL("pods", { namespace: "default" }, { method: "GET", proxy: true })).toBe( - "/api/plugins/kubernetes-logs/proxy/pods?config_id=config-123&namespace=default", - ); - - await runtime.invoke("pods", { namespace: "default" }); + await client.New("kubernetes-logs", "config-123").invoke("pods", { namespace: "default" }); expect(fetchMock.mock.calls[0][0]).toBe( - "/api/plugins/kubernetes-logs/invoke/pods?config_id=config-123", + "https://mc.example.com/api/plugins/kubernetes-logs/invoke/pods?config_id=config-123", ); - expect((fetchMock.mock.calls[0][1] as RequestInit).body).toBe(JSON.stringify({ namespace: "default" })); + expect((fetchMock.mock.calls[0][1] as RequestInit).credentials).toBe("include"); }); - it("supports custom base paths for host embedding", () => { - const runtime = createPluginRuntime({ - pluginRef: "cost", - configId: "abc", - basePath: "/api/mission-control/api/plugins", + it("opens streams through /proxy", () => { + const eventSourceMock = vi.fn(function EventSourceStub( + this: { url: string; options?: EventSourceInit }, + url: string, + options?: EventSourceInit, + ) { + this.url = url; + this.options = options; }); - - expect(runtime.operationURL("monthly")).toBe( - "/api/mission-control/api/plugins/cost/invoke/monthly?config_id=abc", - ); - }); -}); - -describe("createMissionControlClient", () => { - it("uses same-origin credentials for proxy mode", async () => { - const fetchMock = vi.fn(async () => new Response("[]")); - const client = createMissionControlClient({ - mode: "proxy", - baseUrl: "/api/mission-control", - fetch: fetchMock, + const client = createMissionControlPluginClient({ + mode: "pass-through", + baseUrl: "https://mc.example.com", + EventSource: eventSourceMock as unknown as typeof EventSource, }); - await client.plugins.list(); + client.New("kubernetes-logs", "config-123").stream("tail", { pod: "api" }); - expect(fetchMock.mock.calls[0][0]).toBe("/api/mission-control/api/plugins"); - expect((fetchMock.mock.calls[0][1] as RequestInit).credentials).toBe("same-origin"); + expect(eventSourceMock).toHaveBeenCalledWith( + "https://mc.example.com/api/plugins/kubernetes-logs/proxy/tail?config_id=config-123&pod=api", + { withCredentials: true }, + ); }); - it("uses include credentials for pass-through mode", async () => { + it("exports createMissionControlClient as a plugin client alias", async () => { const fetchMock = vi.fn(async () => new Response("{}")); const client = createMissionControlClient({ - mode: "pass-through", - baseUrl: "https://mc.example.com", + mode: "proxy", + baseUrl: "/api/mission-control", fetch: fetchMock, }); - await client.plugins.invoke("kubernetes-logs", "pods", { namespace: "default" }, { - configId: "config-123", - }); + await client.New("kubernetes").invoke("list-pods"); expect(fetchMock.mock.calls[0][0]).toBe( - "https://mc.example.com/api/plugins/kubernetes-logs/invoke/pods?config_id=config-123", + "/api/mission-control/api/plugins/kubernetes/invoke/list-pods", ); - expect((fetchMock.mock.calls[0][1] as RequestInit).credentials).toBe("include"); }); }); From 8cdb379131bdeb6e67e3c46b5c6108731689cb80 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 10 Jun 2026 13:03:31 +0545 Subject: [PATCH 3/3] CI: build example --- .github/workflows/ci.yaml | 45 +++++++++++++++++++++++++++++++++++++++ example/vite.config.ts | 3 +++ 2 files changed, 48 insertions(+) create mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..d7a537e --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,45 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + build: + name: Build and Test + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.11.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Type-check + run: pnpm check + + - name: Test + run: pnpm test + + - name: Build + run: pnpm build + + - name: Install example dependencies + working-directory: example + run: pnpm install --frozen-lockfile + + - name: Build example + working-directory: example + run: pnpm build diff --git a/example/vite.config.ts b/example/vite.config.ts index d642476..c485aac 100644 --- a/example/vite.config.ts +++ b/example/vite.config.ts @@ -14,6 +14,9 @@ export default defineConfig(({ mode }) => { resolve: { dedupe: ['react', 'react-dom'], }, + optimizeDeps: { + exclude: ['@flanksource/plugin-ui-sdk'], + }, server: { port: 5173, proxy: {