Contributing to Meshery UI - Extension Contract and Event Bus
Prerequisite Reading
Table of Contents
- The boundary
- Why there is a contract
- Where the contract lives
- How an extension gets loaded
- The injected capability bag
- The Meshery event bus
- Event reference
- Worked example: adding an event
- Changing the contract
- Tests that gate the boundary
- Troubleshooting
- File reference
The boundary
Meshery extensions β Kanvas being the one most contributors will meet β are not compiled with Meshery UI. They are separately-built, separately-released JavaScript bundles that Meshery Server proxies and Meshery UI fetches, evaluates, and mounts at runtime.
meshery/meshery (host) meshery/meshery-extensions (bundle)
βββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββββ
NavigatorExtension.tsx App.tsx
β β
β injectProps βββββββββββββββββββββββββΊ destructured, normalized,
β (one untyped object) stashed in module-scope maps
β β
β βββββββββββ mesheryEventBus βββββββββββΊ β (bi-directional, typed)
β β
ββββββββββββββββ¬ββββββββββββββββββββββββββββββ
β
@sistent/sistent
mesheryExtensionContract
(event literals, payload types, declared
capabilities, contract version)
Everything crossing that line is either a key on injectProps or an event on
mesheryEventBus. Both are declared once, in sistent, and shared by the two repos.
Why there is a contract
There is no compile-time link across the boundary:
- The host never imports the extension, so TypeScript cannot verify that the keys the host provides are the keys the extension reads.
- The extension never imports the host, so renaming a key here still compiles there.
- Bundle and host are released independently. A bundle built against one revision of the host may be loaded by an older or newer host, indefinitely.
A rename on either side therefore does not fail the build and does not throw. It resolves
to undefined at the extension’s use site, and the feature becomes a silent runtime
no-op. This is not hypothetical β two shipped examples:
| Change | Symptom in production |
|---|---|
OPEN_DESIGN_IN_KANVAS β OPEN_DESIGN_IN_EXTENSION | “Open in Kanvas” published an event nobody was subscribed to. No error; the click did nothing. |
capabilitiesRegistry β providerCapabilities | The extension constructed its access-control object from undefined. No error; permission-gated UI silently misbehaved. |
The contract exists to convert that class of failure into a compile error or a failing test.
Where the contract lives
The contract is declared once, in the mesheryExtensionContract module of
@sistent/sistent. Read that module for the
authoritative definitions. The exports Meshery UI consumes:
| Export | Kind | Purpose |
|---|---|---|
MESHERY_EXTENSION_EVENT | const enum-like object | Every event literal allowed on the bus. Derive every event type from this β never type the string. |
MesheryExtensionEvent | type | Discriminated union of { type, data } across all contract events. |
MesheryExtensionEventBus | type | Type of the shared bus singleton. |
EventBus<T> | class | The bus implementation itself (RxJS-backed). |
MESHERY_EXTENSION_CONTRACT_VERSION | const | Revision of the contract the host was built against; injected into every extension. |
reportInjectedCapabilities | function | Given an injectProps bag, reports which declared capabilities are present and which are missing. |
isInjectedCapabilityReportSatisfied | function | Whether a report has no missing capabilities. |
describeInjectedCapabilityReport | function | Human-readable rendering of a report, used as test-failure output. |
createCanShow | function | Builds the permission-gated CanShow component, and publishes missing-permission / missing-capability events onto the bus it is given. |
Do not add a second declaration of an event literal or an injected key anywhere in
meshery/meshery. Hand-duplicated literals are the exact defect the contract exists to
prevent. If a capability or event is missing, add it to sistent first, then consume it here.
How an extension gets loaded
The full path from provider capability to mounted component:
- The provider declares the extension.
GET /api/provider/capabilitiesreturns anextensionsobject keyed by extension point βnavigator,account,userPrefs,collaborator, plusfull_pageentries. Each entry carries anhref(the route it claims) and acomponent(the bundle URI). - The Navigator renders menu entries for the
navigatorextension point.ui/components/layout/Navigator/Navigator.tsxruns the declared entries throughExtensionPointSchemaValidator('navigator')and renders them as menu items. - The route resolves. Navigating to a declared
hreflands on the catch-all pageui/pages/extension/[...component].tsx, which matches the current path against the provider’s declared extensions and dispatchesextensionTypeinto Redux. ExtensionSandboxpicks the bundle.ui/components/ExtensionSandbox.tsxwalks the extension tree for the entry whosehrefmatches the current path, takes itscomponentURI, and turns it into a fetchable URL withcreatePathForRemoteComponent()β which prefixes/api/provider/extension. The bundle is therefore served through Meshery Server, not fetched cross-origin from the provider.NavigatorExtensionloads and mounts it.ui/components/layout/Navigator/NavigatorExtension.tsxcallsuseRemoteComponent(url), which fetches the bundle, evaluates it as a CommonJS module against the shared runtime, and returnsmodule.exports.default. The host then renders it with exactly one prop:injectProps.
For non-navigator extension points, ExtensionSandbox renders the generic
ui/components/general/RemoteComponent instead, which forwards whatever props it was given
but mounts the bundle without the capability bag. Only the Navigator extension point
receives injectProps. That path also has none of the export-shape diagnostics described
below β a mis-built bundle mounted through it still fails with React’s opaque error, so
debug it against the checklist in Troubleshooting rather than waiting
for a useful message.
The shared runtime
A remote bundle must not ship its own copy of React β two React instances in one page
produce “invalid hook call” failures, and two MUI/emotion instances produce broken theming.
So the bundle is built with those packages as externals, and the host satisfies each
require() at evaluation time from ui/remote-component.config.js:
module.exports = {
resolve: {
react: require('react'),
'react-dom': require('react-dom'),
'@mui/material': require('@mui/material'),
'@emotion/react': require('@emotion/react'),
// β¦plus xstate, redux, rjsf, cytoscape, dockview, xterm, dnd-kit, tippy, β¦
},
};
Three consequences worth knowing:
- A missing key is a hard failure. If a bundle
require()s something absent fromresolve, remote-component throws "β¦ does not exist in dependencies" and the extension does not mount. Adding a new third-party dependency to an extension therefore requires a matching entry here. - CSS subpath imports need stub entries. Bundles do side-effect-only imports such as
require('@xterm/xterm/css/xterm.css'). The actual stylesheets are injected globally inui/pages/_app.tsx, so the config maps those subpaths to{}β a key must exist or the resolver throws, but the value is irrelevant. - Versions must stay in lockstep. The host’s installed version of a shared package is the one the extension gets, regardless of what the extension declared. A major-version bump on either side is a coordinated change.
Export shape and its two failure modes
The bundle must export its component as a CommonJS default:
module.exports = { default: Component, __esModule: true };
In practice that means building with output.library.type = "commonjs2". A bundle built
any other way still loads β it just resolves to nothing useful. Two distinct failures
follow, and NavigatorExtension reports them with different messages on purpose:
| Failure | What the loader returns | Actual cause | Remedy the host reports |
|---|---|---|---|
| No CommonJS default export | RemoteComponent === undefined, err === undefined β the hook does not throw | Bundler configuration | Rebuild with output.library.type = "commonjs2" and republish |
Default export is not a component (e.g. <Foo /> instead of Foo) | An object carrying a $$typeof tag | Defect in the extension’s own source | Export the component itself, not a rendered element |
Without those guards, both cases surface only as React’s opaque “Element type is invalid β¦ got: undefined”, thrown from deep inside the render tree, far from the real problem.
The component check is deliberately an allow-list of $$typeof tags
(react.forward_ref, react.memo, react.lazy) rather than a bare '$$typeof' in value
test. Many React-internal values carry $$typeof without being component types β most
notably an already-rendered element, which is precisely the mistake the second row above
describes. A bare check would wave it straight through.
If an extension renders blank with nothing in the console, inspect the bundle’s export shape before anything else.
The injected capability bag
buildExtensionInjectProps in NavigatorExtension.tsx builds the bag. It is deliberately a
pure factory β everything it needs from React state or context arrives as a parameter β
so a unit test can build the bag and assert it against the contract without rendering
anything. Keep it that way; the moment it reads a hook directly, the gate test gets harder
to write than the shortcut is worth.
The component memoizes the result on its inputs, so extensions are not re-rendered by an unrelated store update.
What is in the bag
| Group | Keys |
|---|---|
| Contract | contractVersion |
| Components | PatternServiceFormCore, InfoModal, ViewInfoModal, ExportModal, GenericRJSFModal, RJSForm, TypingFilter, CreateModelModal, ImportModelModal, ValidateDesign, DryRunDesign, DeployStepper, UnDeployStepper, MesheryPerformanceComponent, StructuredDataFormatter, RelationshipEvaluationResponseFormatter, ThemeTogglerCore, _PromptComponent |
| Hooks | hooks.CAN, hooks.useHasPermission, hooks.useFilterK8sContexts, hooks.useDynamicComponent, and useNotificationHook at top level |
| State | providerCapabilities, selectedK8sContexts, currentOrganization, mesheryStore |
| Callbacks | openWorkspaceModal, openRegistryModal, SetCurrentLoadedResourceInOrgWorkspaceSession |
| Machines | designValidationMachine |
| Bus | mesheryEventBus |
| Access control | ProviderUiAccessControlClass (and its legacy alias CapabilitiesRegistryClass) |
| Legacy | resolver |
Two of these deserve elaboration:
mesheryStore is getter-based, not a snapshot.
const extensionExposedMesheryStore = {
selectedK8sClusters: { get: () => selectSelectedK8sClusters(store.getState()) },
k8sConfig: { get: () => selectK8sConfig(store.getState()) },
};
The extension calls .get() at the moment it needs the value, so it always reads live
store state. Handing over a plain value instead would freeze it at mount time. Add new
store exposure the same way.
ProviderUiAccessControlClass is a class, not an instance. The host passes the class;
the extension constructs it with the raw /api/provider/capabilities payload
(new CapabilitiesRegistryClass(capabilitiesRegistry)). ProviderUiAccessControl
(ui/utils/disabledComponents.js) answers the provider-driven questions β
isNavigatorComponentEnabled, isHeaderComponentEnabled, isExtensionComponentEnabled β
which in a restricted (playground) environment walk restrictedAccess.allowedComponents to
decide whether a given piece of UI may render at all.
How the extension consumes it
On the extension side (meshery-extensions/ui/src/App.tsx and
ui/src/globals/mesherySdk.ts), the bag is destructured once at the top level and stashed
into module-scoped maps, so deeply-nested extension code can reach it without prop drilling:
setMesheryFunctionality("mesheryEventBus", mesheryEventBus);
setMesheryFunctionality("CAN", normalizedCAN);
injectedReactComponents.set(GLOBAL_COMPONENTS.ValidateDesign, normalizedValidateDesign);
injectedMachines.set(GLOBAL_MACHINES.DesignValidatorMachine, normalizedDesignValidationMachine);
and read back through typed accessors β getMesheryEventBus(), getCapabilitiesRegistry(),
createInjectedComponent("ExportDesignModal"), and so on. injectedMesheryFunctionalitiesMap
in mesherySdk.ts is the extension’s local typing of the same contract.
Every value passes through normalizeInjectedValue first. It repeatedly unwraps
{ default, __esModule } wrappers until it reaches the real value:
while (hasDefaultExport(resolvedValue) && !seen.has(resolvedValue)) {
resolvedValue = resolvedValue.default;
}
This exists because the ESM/CJS interop between host build, remote-component evaluation and
bundle build can double-wrap an injected component, so Component arrives as
{ default: Component }. Rendering that directly fails with the same opaque React error as
a bad export. If you inject a value that is itself an object with a default key, be
aware it will be unwrapped β the seen set only guards against cycles, not against
legitimately-shaped data.
Contract versioning
contractVersion: MESHERY_EXTENSION_CONTRACT_VERSION is injected into every bundle. Nothing
at build time can catch a host/bundle mismatch, because bundles are published artifacts
loaded by whichever host happens to be deployed. The version gives the extension a runtime
signal it can use to degrade gracefully or warn, rather than silently misbehaving.
Bump it in sistent whenever a change is not backward-compatible for already-published bundles.
Backward compatibility
Published bundles are loaded by new hosts, so keys cannot simply be deleted:
CapabilitiesRegistryClassis retained as an alias ofProviderUiAccessControlClass. The rename happened here; the alias keeps older bundles working. The unit test asserts the two keys are the same reference, so the alias cannot drift.resolveris retained even though Meshery Server no longer exposes any GraphQL subscription. Published bundles callresolver.subscription.ConfigurationSubscription(cb, vars)and later.dispose()on the returned handle; handing themundefinedwould throw at mount. The host supplies an inert subscription matching the old call shape β(onNext, variables) => ({ dispose })β that never invokesonNext. New extensions must read designs and filters from the REST API.
When you retire a capability, keep an alias or an inert stand-in until every published bundle has been rebuilt, and leave a comment saying why the dead-looking key is there. Otherwise the next contributor deletes it as cruft.
Worked example: adding a capability
Say you want extensions to be able to open the environment-selection modal.
Declare it in sistent. Add
openEnvironmentModalto the declared capability list inmesheryExtensionContractsoreportInjectedCapabilitiesknows to look for it.Add the parameter to the factory in
NavigatorExtension.tsxβ it comes from a hook or context, so it must be a parameter, not a direct hook call:export type ExtensionInjectPropsDeps = { // β¦existing openEnvironmentModal: unknown; }; export const buildExtensionInjectProps = ({ /* β¦ */ openEnvironmentModal }) => ({ // β¦existing openEnvironmentModal, });Wire it at the call site, inside the existing
useMemo, and add it to the dependency array β a missed dependency here hands the extension a stale callback.Consume it in the extension: add it to
AppProps['injectProps']andinjectedMesheryFunctionalitiesMap, thensetMesheryFunctionality("openEnvironmentModal", β¦).Run
npm testinui/. The capability-report assertion inNavigatorExtension.test.tsxnow covers the new key; if you forgot step 2 or 3 it fails here rather than in production.
The Meshery event bus
ui/utils/eventBus.ts exports the singleton:
import { EventBus, type MesheryExtensionEvent, type MesheryExtensionEventBus } from '@sistent/sistent';
export const mesheryEventBus: MesheryExtensionEventBus = new EventBus<MesheryExtensionEvent>();
EventBus<T> has no default for T. A bare new EventBus() collapses T to its
constraint, and publish() then silently accepts any { type: string } β disabling
publish-site checking entirely, which is the single check standing between a typo and a
dead feature. Keep this bus typed as EventBus<MesheryExtensionEvent>.
Do not use mesheryEventBus for intra-UI communication. Within Meshery UI, use Redux
dispatch or XState events. Every literal on this bus is a cross-repo API surface; adding one
for a purely internal purpose burdens the extension repo with a contract it will never use.
Delivery semantics
The implementation is a thin wrapper over an RxJS Subject:
publish(event) { this.eventSubject.next(event); }
on(type) { return this.eventObservable.pipe(filter((e) => e.type === type)); }
onAny() { return this.eventObservable; }
That has three practical consequences:
- No replay, no buffering. It is a plain
Subject, not aReplaySubjectorBehaviorSubject. An event published while nobody is subscribed is dropped permanently β a late subscriber never sees it. This is the reason the navigation publishers first checkisExtensionOpen(): publishing to an unmounted extension would be a no-op, so they fall back to a router push instead. - Delivery is synchronous.
publish()runs every matching subscriber before it returns. Do not rely on a publish being deferred, and be aware that an exception thrown by one subscriber propagates back into the publisher. on()filters at runtime but is typed as the full union. See the narrowing note under Subscribing.
Subscriptions are RxJS subscriptions. Hold the handle and unsubscribe() on teardown β
this bus is a module-scoped singleton that outlives every component, so a leaked
subscription leaks for the lifetime of the page.
| Method | Behavior |
|---|---|
publish(event) | Emits a MesheryExtensionEvent. Type-checked against the contract union. Synchronous. |
on(type) | Observable of events of that type only. |
onAny() | Observable of every event, regardless of type. Useful for debugging; avoid in feature code. |
Publishing
Always derive the literal from the enum:
import { MESHERY_EXTENSION_EVENT } from '@sistent/sistent';
import { mesheryEventBus } from '@/utils/eventBus';
mesheryEventBus.publish({
type: MESHERY_EXTENSION_EVENT.OpenDesignInExtension,
data: { designId, designName },
});
The host’s navigation helpers in ui/utils/utils.tsx all follow the same shape β publish if
the extension is mounted, otherwise navigate:
export const openDesignInExtension = (designId, designName, router) => {
if (isExtensionOpen()) {
mesheryEventBus.publish({
type: MESHERY_EXTENSION_EVENT.OpenDesignInExtension,
data: { designId, designName },
});
return;
}
router.push(`/extension/meshmap?mode=design&type=design&id=${designId}`);
};
Preserve that branch when adding similar actions. Given the no-replay semantics above, publishing without the guard means the action is silently lost whenever the extension is not already mounted.
Subscribing
ui/store/index.ts wires the store to the bus and shows the one non-obvious detail:
mesheryEventBus.on(MESHERY_EXTENSION_EVENT.DispatchToMesheryStore).subscribe((event) => {
// `EventBus.on` filters at runtime but is typed as the full event union, so the
// discriminant has to be re-checked here for `event.data` to narrow to a
// dispatchable action rather than the union of every event payload.
if (event.type !== MESHERY_EXTENSION_EVENT.DispatchToMesheryStore) return;
store.dispatch(event.data);
});
The redundant-looking if is required for narrowing β on() is declared as
Observable<T>, not Observable<Extract<T, { type: E }>>. Do not delete it.
On the extension side the same bus is bridged into XState with fromCallback, so a state
machine can receive host events as ordinary machine events:
export const MesheryEventsSubscriberActor = (events: MesheryEventTypes["type"][]) =>
fromCallback(({ sendBack }) => {
const subscribers = events.map((eventType) =>
getMesheryEventBus().on(eventType).subscribe(sendBack)
);
return () => subscribers.forEach((s) => s.unsubscribe());
});
Note the teardown function β that is the unsubscribe() discipline described above, and the
pattern to copy for any long-lived subscriber.
Event reference
MESHERY_EXTENSION_EVENT in sistent is authoritative. The events Meshery UI publishes or
subscribes to today:
| Enum member | Wire literal | Direction | Payload | Site |
|---|---|---|---|---|
DispatchToMesheryStore | DISPATCH_TO_MESHERY_STORE | extension β host | { type, payload? } β a Redux action | consumed in ui/store/index.ts |
K8sContextsUpdated | K8S_CONTEXTS_UPDATED | host β extension | { selectedK8sContexts } | setK8sContexts thunk, ui/store/slices/mesheryUi.ts |
OpenDesignInExtension | OPEN_DESIGN_IN_EXTENSION | host β extension | { designId, designName } | openDesignInExtension, ui/utils/utils.tsx |
OpenViewInExtension | OPEN_VIEW_IN_EXTENSION | host β extension | { viewId, viewName } | openViewInExtension, ui/utils/utils.tsx |
OpenViewScopedToDesign | OPEN_VIEW_SCOPED_TO_DESIGN | host β extension | { designId, designName } | openViewScopedToDesignInOperator, ui/utils/utils.tsx |
MergeDesign | MERGE_DESIGN | host β extension | { id, name } | mergeDesignWithCurrent, ui/utils/utils.tsx |
Plus the access-control events that shared sistent components emit onto whichever bus they
are given β in Meshery UI that wiring is createCanShow(getProviderUiAccessControl, CAN, () => mesheryEventBus)
in ui/utils/can.ts:
| Wire literal | Direction | Meaning |
|---|---|---|
MISSING_PERMISSION | host β extension | A CanShow-gated element was denied by the user’s abilities. |
MISSING_CAPABILITY | host β extension | A CanShow-gated element was denied by the provider’s declared capabilities. |
FeatureRequiresUserAccount | either | A feature was attempted that requires a signed-in account. |
Kanvas subscribes to those three to drive its anonymous-user flow β prompting sign-in or an upgrade instead of rendering a dead control.
This event lets an extension dispatch arbitrary Redux actions into the host store. That makes host action-type strings and payload shapes an implicit part of the contract: renaming a slice action can break an extension even though no contract file changed. Prefer a purpose-built event, or an injected callback, over widening what extensions dispatch.
Worked example: adding an event
Say the host needs to tell the extension that the active workspace changed.
Declare it in sistent β add the literal to
MESHERY_EXTENSION_EVENTand the payload member to theMesheryExtensionEventunion:WorkspaceChanged: 'WORKSPACE_CHANGED', // β¦ type WorkspaceChangedEvent = { type: typeof MESHERY_EXTENSION_EVENT.WorkspaceChanged; data: { workspaceId: string; workspaceName: string }; };Publish from the host, deriving the literal from the enum, and guarding with
isExtensionOpen()if the event is a navigation action:mesheryEventBus.publish({ type: MESHERY_EXTENSION_EVENT.WorkspaceChanged, data: { workspaceId, workspaceName }, });Assert the publish site in the owning slice/util test, the way
ui/store/slices/__tests__/mesheryUi.test.tsassertsK8sContextsUpdated.Subscribe in the extension β add the literal to
MesheryEventTypesand to the relevantMesheryEventsSubscriberActorlist (or a direct.on(...).subscribe(...)with teardown).Bump
MESHERY_EXTENSION_CONTRACT_VERSIONif the change is not backward-compatible.Update the event reference table on this page.
Skipping step 6 is how the table goes stale, and a stale table is worse than none β it is what contributors read instead of the sistent module.
Changing the contract
The order matters. Sistent first, then host, then extension:
- Change sistent. Add the event literal and payload, or the declared capability, in
mesheryExtensionContract. - Bump
MESHERY_EXTENSION_CONTRACT_VERSIONfor anything not backward-compatible with already-published bundles. - Update the host β publish/subscribe via the enum member; add the capability to
buildExtensionInjectPropsand its call site’suseMemodependencies. - Update
meshery/meshery-extensionsto read the new key or handle the new event. - Keep an alias or inert stand-in for anything renamed or removed until published bundles are rebuilt.
- Run
npm testinui/and update this page.
Working against an unreleased sistent
Contract changes almost always mean an unreleased @sistent/sistent. During development,
ui/node_modules/@sistent/sistent is typically overwritten in place with a locally-built
dist. A green local run then proves nothing about CI, which installs the published package.
Re-verify with npm ci before declaring a contract change done. A local build usually keeps
the published version string, so matching versions are not evidence that the installed
contents are the published ones. A version mismatch against ui/package.json is a useful
tell that this has happened; a match proves nothing.
Forbidden
- MUST NOT type an event literal by hand β derive it from
MESHERY_EXTENSION_EVENT. - MUST NOT construct the bus as a bare
new EventBus(); it must benew EventBus<MesheryExtensionEvent>(). - MUST NOT use
mesheryEventBusfor communication that stays inside Meshery UI. - MUST NOT redeclare contract events or injected keys locally in
meshery/meshery. - MUST NOT drop an injected key without an alias or inert stand-in while older bundles are still in the wild.
- MUST NOT call hooks directly inside
buildExtensionInjectPropsβ it must stay a pure factory so the gate test can call it. - MUST NOT add a third-party dependency to an extension without a matching entry in
ui/remote-component.config.js.
Tests that gate the boundary
Run with npm test in ui/ (Vitest).
| File | What it guarantees |
|---|---|
ui/components/layout/Navigator/NavigatorExtension.test.tsx | The bag built by buildExtensionInjectProps satisfies every capability the contract declares, via reportInjectedCapabilities / isInjectedCapabilityReportSatisfied, with describeInjectedCapabilityReport as the failure message. This is the gate that catches a capability rename before merge. Also covers contractVersion being injected, the CapabilitiesRegistryClass alias holding, the getter-based mesheryStore, the missing-default-export and not-a-component diagnostics (including that only the former mentions commonjs2), memo/forwardRef-wrapped exports, and that a missing export is not reported while still loading. |
ui/utils/__tests__/eventBus.test.ts | The bus is a singleton exposing publish/on/onAny; on filters by type while onAny does not; unsubscribing stops delivery; and β via @ts-expect-error β an event literal outside the contract fails the build. |
ui/store/slices/__tests__/mesheryUi.test.ts | The setK8sContexts thunk publishes K8sContextsUpdated with the selected contexts. |
If you add an event, add a publish-site assertion alongside these. An event nobody asserts on is indistinguishable from an event nobody receives.
Troubleshooting
| Symptom | Likely cause | Where to look |
|---|---|---|
| Extension area is blank, no console error | Bundle has no CommonJS default export | The host’s own error panel now reports this; rebuild with output.library.type = "commonjs2" |
| React: “Element type is invalid β¦ got: undefined” | Same as above, from an older host, or a double-wrapped injected component | Bundle export shape; normalizeInjectedValue on the extension side |
| "β¦ does not exist in dependencies" | Bundle require()s a package absent from the shared runtime | ui/remote-component.config.js |
| “Invalid hook call” inside the extension | Two React copies β the bundle shipped its own instead of externalizing it | Extension build config; resolve.react in the shared runtime |
| A host action does nothing in the extension | Event literal renamed on one side, or published while the extension was unmounted | MESHERY_EXTENSION_EVENT; the isExtensionOpen() guard |
An injected value is undefined in the extension | Capability renamed or dropped, or missing from the useMemo deps | buildExtensionInjectProps; run the gate test |
| A feature works locally, fails in CI | node_modules/@sistent/sistent overwritten with a local dist | Re-run npm ci and retest |
| Permission-gated UI misbehaves | ProviderUiAccessControl constructed from the wrong payload | ui/utils/disabledComponents.js; capabilitiesRegistry vs providerCapabilities |
File reference
| Path | Role |
|---|---|
ui/utils/eventBus.ts | The singleton bus. |
ui/components/layout/Navigator/NavigatorExtension.tsx | Loads the bundle; builds and injects the capability bag; reports load failures. |
ui/components/ExtensionSandbox.tsx | Resolves which bundle URI serves the current path, for each extension point. |
ui/pages/extension/[...component].tsx | Catch-all route for full-page extensions. |
ui/remote-component.config.js | Shared runtime handed to remote bundles. |
ui/utils/utils.tsx | Host-side navigation publishers and isExtensionOpen(). |
ui/store/index.ts | Subscribes the Redux store to DispatchToMesheryStore. |
ui/store/slices/mesheryUi.ts | Publishes K8sContextsUpdated. |
ui/utils/can.ts | Wires createCanShow to the bus for permission/capability events. |
ui/utils/disabledComponents.js | ProviderUiAccessControl β provider-driven UI gating. |
@sistent/sistent β mesheryExtensionContract | The contract itself. Authoritative. |
meshery-extensions/ui/src/App.tsx | Extension-side entry point; destructures injectProps. |
meshery-extensions/ui/src/globals/mesherySdk.ts | Extension-side accessors and local typing of the contract. |