diff --git a/plugins/example-plugin/web/AGENTS.md b/plugins/example-plugin/web/AGENTS.md index 24c75a6078..8cde6314ff 100644 --- a/plugins/example-plugin/web/AGENTS.md +++ b/plugins/example-plugin/web/AGENTS.md @@ -30,7 +30,7 @@ Runtime contract: `../../../web/packages/studio/src/plugins/types.ts`. | Deps | externalize the shared set in `vite.config.ts`; bundle the rest | bundle react / react-dom / react-router / foundations | Studio injects everything a plugin needs through a **single `host` prop** -(`host.workspaceId`, `host.auth`, `host.sdk`, `host.navigation`, +(`host.workspaceId`, `host.apiBaseUrl`, `host.auth`, `host.sdk`, `host.navigation`, `host.notifications`, `host.telemetry`, `host.breadcrumbs`) — grouped so new capabilities extend the handle without changing `Root`'s signature. Destructure what you use. All are backed by Studio's own singletons: `notifications` fires into Studio's shared @@ -57,6 +57,12 @@ declares a minimal structural `PluginSdk` covering only the hooks this example calls. A real plugin either mirrors the calls it needs the same way or, if it can resolve the SDK's types, types `host.sdk` as Studio does. +`host.sdk` covers **platform** services only. A plugin that calls *its own* +service ships its own client, and must prefix every request with +`host.apiBaseUrl` — Studio's dev-server `/apis` proxy is opt-in, so a bare +`/apis/...` request hits the dev server rather than the platform whenever +`VITE_PLATFORM_BASE_URL` is set. + ## Shared UI (`@nemo/common`) Studio's own table, form, and status components are shared the same way KUI is. @@ -103,6 +109,7 @@ import { StudioDataView, useStudioDataViewState } from '@nemo/common'; { host: { workspaceId: string; + apiBaseUrl: string; auth: { accessToken: string; getAccessToken: () => string }; sdk: { platform: /* @nemo/sdk platform hooks */ }; navigation: { navigate: (to: string) => void; back: () => void }; diff --git a/plugins/example-plugin/web/src/types.ts b/plugins/example-plugin/web/src/types.ts index f6955147e8..2413d7ecff 100644 --- a/plugins/example-plugin/web/src/types.ts +++ b/plugins/example-plugin/web/src/types.ts @@ -61,6 +61,8 @@ export interface PluginTelemetry { export interface PluginHost { workspaceId: string; + /** Origin the platform API is served from; empty when same-origin. */ + apiBaseUrl: string; auth: { accessToken: string; getAccessToken: () => string; diff --git a/web/packages/common/src/plugin.ts b/web/packages/common/src/plugin.ts index c0a2a4c864..5b86d00c6a 100644 --- a/web/packages/common/src/plugin.ts +++ b/web/packages/common/src/plugin.ts @@ -6,9 +6,11 @@ export { AccessibleTitle } from '@nemo/common/src/components/AccessibleTitle'; export { AccordionSection } from '@nemo/common/src/components/AccordionSection'; +// CancelJobButton is deliberately NOT exported export { ConfirmationModal } from '@nemo/common/src/components/ConfirmationModal'; export { DeleteConfirmationModal } from '@nemo/common/src/components/DeleteConfirmationModal'; export type { AccordionSectionProps } from '@nemo/common/src/components/AccordionSection'; +// ErrorPanel is deliberately NOT exported export { ExpandableMessage } from '@nemo/common/src/components/ExpandableMessage'; export { FileTag } from '@nemo/common/src/components/FileTag'; export type { FileTagProps, FileTagStatus } from '@nemo/common/src/components/FileTag'; @@ -41,6 +43,7 @@ export type { BadgeStatus, StatusConfigEntry } from '@nemo/common/src/components export { TableEmptyState } from '@nemo/common/src/components/TableEmptyState'; export { ControlledSelect } from '@nemo/common/src/components/form/ControlledSelect'; +export { ControlledTextArea } from '@nemo/common/src/components/form/ControlledTextArea'; export { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput'; export { useStudioDataViewState } from '@nemo/common/src/hooks/useStudioDataViewState'; @@ -60,6 +63,8 @@ export { } from '@nemo/common/src/utils/query'; export { triggerDownload } from '@nemo/common/src/utils/file'; +export { ENTITY_NAME_HELP, entityNameSchema } from '@nemo/common/src/utils/entityName'; + export { getErrorMessage } from '@nemo/common/src/utils/error'; export { handleFormErrorsGeneric } from '@nemo/common/src/utils/forms/error'; export { logger, toError } from '@nemo/common/src/utils/logger'; diff --git a/web/packages/studio/src/plugins/PluginRenderer.test.tsx b/web/packages/studio/src/plugins/PluginRenderer.test.tsx index 77d3b19a22..cd55140e69 100644 --- a/web/packages/studio/src/plugins/PluginRenderer.test.tsx +++ b/web/packages/studio/src/plugins/PluginRenderer.test.tsx @@ -108,6 +108,9 @@ describe('PluginRenderer', () => { expect(typeof capturedProps?.host.navigation.navigate).toBe('function'); expect(typeof capturedProps?.host.notifications.notify).toBe('function'); expect(typeof capturedProps?.host.telemetry.event).toBe('function'); + // A plugin calling its own service prefixes requests with this; it must be a + // string even when unset, so `${apiBaseUrl}${path}` never yields "undefined/...". + expect(typeof capturedProps?.host.apiBaseUrl).toBe('string'); }); it('does not remount on token renewal and getAccessToken returns the new token', () => { diff --git a/web/packages/studio/src/plugins/PluginRenderer.tsx b/web/packages/studio/src/plugins/PluginRenderer.tsx index 10f888b511..52605e3f98 100644 --- a/web/packages/studio/src/plugins/PluginRenderer.tsx +++ b/web/packages/studio/src/plugins/PluginRenderer.tsx @@ -4,6 +4,7 @@ import { useToast } from '@nemo/common/src/providers/toast/useToast'; import { logger } from '@nemo/common/src/utils/logger'; import * as platformSdk from '@nemo/sdk/generated/platform/api'; +import { PLATFORM_BASE_URL } from '@studio/constants/environment'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { usePlugins, usePluginsLoaded } from '@studio/plugins/PluginContext'; import { PluginErrorBoundary } from '@studio/plugins/PluginErrorBoundary'; @@ -61,6 +62,7 @@ export const PluginRenderer = (): ReactElement => { const host = useMemo( () => ({ workspaceId: workspace, + apiBaseUrl: PLATFORM_BASE_URL ?? '', auth: { accessToken, getAccessToken }, sdk: STUDIO_SDK, navigation: { navigate: (to) => navigate(to), back: () => navigate(-1) }, diff --git a/web/packages/studio/src/plugins/types.ts b/web/packages/studio/src/plugins/types.ts index c3e6f07cf9..e149058f52 100644 --- a/web/packages/studio/src/plugins/types.ts +++ b/web/packages/studio/src/plugins/types.ts @@ -56,6 +56,13 @@ export interface PluginTelemetry { /** The host handle Studio injects into every plugin; extend it to add capabilities. */ export interface PluginHost { workspaceId: string; + /** + * Origin the platform API is served from; empty when same-origin. A plugin + * that calls its own service needs this: Studio's dev-server `/apis` proxy is + * opt-in, so a relative request would otherwise hit the dev server whenever + * VITE_PLATFORM_BASE_URL is set. + */ + apiBaseUrl: string; // Access tokens only — refresh tokens must not cross the boundary. auth: { accessToken: string;