From a53a9985ddd7d92c41dd7f167d1c657175ddfcb1 Mon Sep 17 00:00:00 2001 From: mschwab Date: Wed, 12 Aug 2026 15:55:24 -0700 Subject: [PATCH 1/3] feat(studio): add host.apiBaseUrl and guard the plugin surface against axios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes to the Studio plugin system, both found while building the first plugin that calls its own service. host.apiBaseUrl A plugin that ships its own client had no way to reach the platform. Studio's dev-server `/apis` proxy is opt-in (gated on VITE_PLATFORM_PROXY_DOMAIN and an empty base URL), so a relative `/apis/...` request hits the dev server rather than the platform whenever VITE_PLATFORM_BASE_URL is set. host.sdk covers platform services only, so there was no workaround short of reaching into Studio's internals. @nemo/common plugin surface Anything exported from plugin.ts is bundled into vendor/common.js, which every plugin loads through the import map. Two components reach axios — ErrorPanel via api/common/utils, CancelJobButton via @nemo/sdk/generated/platform/api — and axios resolves to its Node build there, emitting bare imports of crypto, http, https, url, events, stream and zlib. The browser cannot resolve those, so the dynamic import of *every* plugin throws, and loadPlugin swallows it with a logger.warn: the only symptom is a plugin silently not appearing. Neither is exported, and both now carry a comment saying why, since the failure mode gives no hint about the cause. Also adds ControlledTextArea, ENTITY_NAME_HELP and entityNameSchema to the surface — all free of SDK and axios reach. Signed-off-by: mschwab --- plugins/example-plugin/web/AGENTS.md | 9 ++++++++- plugins/example-plugin/web/src/types.ts | 2 ++ web/packages/common/src/plugin.ts | 13 +++++++++++++ web/packages/studio/src/plugins/PluginRenderer.tsx | 2 ++ web/packages/studio/src/plugins/types.ts | 7 +++++++ 5 files changed, 32 insertions(+), 1 deletion(-) 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..a793f41a1c 100644 --- a/web/packages/common/src/plugin.ts +++ b/web/packages/common/src/plugin.ts @@ -6,9 +6,19 @@ export { AccessibleTitle } from '@nemo/common/src/components/AccessibleTitle'; export { AccordionSection } from '@nemo/common/src/components/AccordionSection'; +// CancelJobButton is deliberately NOT exported: it imports +// @nemo/sdk/generated/platform/api, whose fetcher pulls axios and +// oidc-client-ts — and those resolve Node builtins (crypto, http, url) in the +// vendor build, which the browser cannot resolve. Adding it here breaks *every* +// plugin's dynamic import, not just the one using it. A plugin that needs it +// builds its own on host.sdk.platform. 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: it reaches axios through +// api/common/utils, and axios resolves Node builtins (crypto, http, url) in the +// vendor build — which the browser cannot resolve, breaking the dynamic import +// of *every* plugin, not just one that uses it. 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 +51,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 +71,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.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; From e354df1a4a2e8c06f7cec8625f0f74f522e6dfbc Mon Sep 17 00:00:00 2001 From: mschwab Date: Wed, 12 Aug 2026 16:02:02 -0700 Subject: [PATCH 2/3] test(studio): assert host.apiBaseUrl reaches the plugin The renderer builds it from PLATFORM_BASE_URL, which is optional. Asserting the type catches the undefined case, where every plugin request would be prefixed with the string "undefined". Signed-off-by: mschwab --- web/packages/studio/src/plugins/PluginRenderer.test.tsx | 3 +++ 1 file changed, 3 insertions(+) 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', () => { From dd77167640078e75424d44cd50e955777ec5f8f5 Mon Sep 17 00:00:00 2001 From: mschwab Date: Wed, 12 Aug 2026 16:06:28 -0700 Subject: [PATCH 3/3] fix comment Signed-off-by: mschwab --- web/packages/common/src/plugin.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/web/packages/common/src/plugin.ts b/web/packages/common/src/plugin.ts index a793f41a1c..5b86d00c6a 100644 --- a/web/packages/common/src/plugin.ts +++ b/web/packages/common/src/plugin.ts @@ -6,19 +6,11 @@ export { AccessibleTitle } from '@nemo/common/src/components/AccessibleTitle'; export { AccordionSection } from '@nemo/common/src/components/AccordionSection'; -// CancelJobButton is deliberately NOT exported: it imports -// @nemo/sdk/generated/platform/api, whose fetcher pulls axios and -// oidc-client-ts — and those resolve Node builtins (crypto, http, url) in the -// vendor build, which the browser cannot resolve. Adding it here breaks *every* -// plugin's dynamic import, not just the one using it. A plugin that needs it -// builds its own on host.sdk.platform. +// 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: it reaches axios through -// api/common/utils, and axios resolves Node builtins (crypto, http, url) in the -// vendor build — which the browser cannot resolve, breaking the dynamic import -// of *every* plugin, not just one that uses it. +// 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';