Extensions
Overview
Extension is a widget without a visual part. Extends the platform API in ways not originally intended. Always implemented as a class.
Extensions are available for all widgets. The functionality brought in this way can be utilized anywhere in the system. Extensions are always public and anyone can use them.
Each project has different Extensions because different vendors need different functionality. A single scope is a contract, not a single implementation: every tenant ships its own build under the same scope, and a given build may implement only a subset of the scope's methods (see preset_list_ext). Consumers must therefore probe optional members with ?..
The public contract for each scope is declared as an I*Ext interface in @invent/shared-types, and getExtension(scope) is typed per scope through the overloaded GetExtensionT — so consumers get the right instance type without casting.
Extension Scopes
Extensions are categorized by scope, which determines their purpose and available methods.
http_client_ext
Custom HTTP client extension for making API requests with project-specific configuration.
Purpose: Provides a shared, pre-authenticated HTTP client (token + role headers + per-tenant request/response middleware baked in). The middleware differs per tenant build; this public shape does not.
Contract:
interface IHttpClientExt {
getHttpClient(): WrappedHttpClient;
}
// The returned client is generic per call: T is the expected response shape.
type WrappedHttpClient = <T>(
apiUrl?: string,
opts?: IRequestOpts
) => Promise<T | undefined>;
preset_list_ext
Manages widget presets and templates for dashboard composition.
Purpose: Provides pre-configured widget templates that users can add to dashboards.
This scope is a capability bag, not a single operation: each tenant build implements a different subset. Therefore every member is optional and consumers must probe with ?.. presetsList is intentionally loosely typed because its signature collides across tenants; narrow the result at the call site.
Contract:
interface IPresetListExt {
presetListAsync?(props: {
httpClient?: HttpClientT<unknown>;
apiUrl: string;
}): Promise<unknown[]>;
presetsByWidgetName?(name: string): unknown[];
knownPresets?(): string[];
widgetTemplatesAccessRole?: string;
widgetPresetFpRole?: string;
// Signature varies by tenant
presetsList?(...args: unknown[]): unknown[];
}
filter_widgets_ext
Filters and modifies widget lists based on dashboard context.
Purpose: Given the current dashboard context, returns the widget list filtered to what the user should see (role / region / profile rules live inside the per-tenant build).
Contract:
interface IFilterWidgetsExt {
updateWidgetListAsync(props: {
httpClient: HttpClientT<unknown>;
apiUrl: string;
useSelectSharedValue: <T>(
selector: (state: ISharedState) => T,
comparator?: Comparator<T>
) => T;
dashboardId: string | null;
widgets: unknown[];
}): Promise<unknown[]>;
}
// Usage (host side)
const filterWidgetsExt = getExtension("filter_widgets_ext");
const filteredWidgets = await filterWidgetsExt?.updateWidgetListAsync({
httpClient,
apiUrl: window.API_BASE_URL,
useSelectSharedValue,
dashboardId,
widgets,
});
companion_bff_ws_ext
A shared real-time message channel to the BFF.
Purpose: Lets widgets subscribe to named server-pushed messages. The contract is transport-agnostic — the underlying connection (e.g. a WebSocket hub) is an internal detail of the extension and is intentionally not exposed.
Contract:
interface IBffWsExt {
subscribe(messageName: string, handler: BffWsMessageHandler): void;
unsubscribe(messageName: string, handler: BffWsMessageHandler): void;
}
type BffWsMessageHandler = (...args: any[]) => void;
analytics_ext
A per-tenant analytics service (GA4 / GTM / …) hidden behind a single interface.
Purpose: Centralizes product analytics so the host and any widget can report events through one capability, without knowing which tracker is underneath. Replaces the previous approach of shipping analytics as a (headless) widget.
Contract:
interface IAnalyticsExt {
// Initialize the underlying tracker. Idempotent: repeated calls are a no-op.
init(config: AnalyticsConfig): void;
// Report a tracked event. Safe to call before init (buffered until ready,
// dropped if the extension disabled itself). Maps to the underlying tracker
// (e.g. ga4.event / dataLayer.push).
track(event: string, params?: AnalyticsEventParams): void;
// Set persistent properties under `key`, attached to all subsequent events
// (GA4: gtag('set', key, properties), key typically 'user_properties').
// Optional capability; same pre-init semantics as track.
setProperties?(key: string, properties: AnalyticsEventParams): void;
// Identify the signed-in user with an internal, non-PII id, or clear the
// identity with null on sign-out (GA4: gtag('set', { user_id });
// GTM: dataLayer push). Optional capability; same pre-init semantics as track.
setUser?(userId: string | null): void;
}
type AnalyticsConfig = Record<string, unknown>;
type AnalyticsEventParams = Record<string, unknown>;
Lifecycle / responsibilities:
-
The host initializes it once, after all extensions have loaded, by forwarding the raw platform config — it stays provider-agnostic and does not decide whether analytics should run. It also reports what only the host knows about the session: a
loginevent once per interactive login (not on reload, silent renew or a second tab), alogoutevent with its reason (user,idle,absolute,renew-failed), andsetUser(null)on sign-out:// entry-pointconst analytics = getExtension("analytics_ext");analytics?.init(window.CUSTOM_ENV);return bindAuthAnalytics(analytics); // login / logout / setUser(null) -
The extension owns everything provider-specific: it validates its own config (e.g.
gaTagfor GA4,gtmIdfor GTM) and disables itself when the required key is absent or for excluded users;initis idempotent and the implementation buffers calls made before initialization. The GA4 build also picks the user id up itself ininit— the access token'ssub, an opaque platform GUID — and passes it into thegtag('config')call so the automaticpage_viewalready carries it (PSDK-5633). Per-tenant keys of the GA4 build:gaTag(measurement id, required),gaUserIdDisabled(trueopts the tenant out of User-ID),gaDebug(truesendsdebug_modefor GA4 DebugView; staging only). -
Widgets report events through
getExtension("analytics_ext")?.track(...)and role information throughsetProperties?.("user_properties", {...}). Auto-events that depend on reactive state (current dashboard, role, route) are pushed from the host/module that can observe that state, since a plain class cannot use hooks. Never pass PII (email, name) as an event parameter, user property or user id.
Types
Extension Metadata
type ExtensionScopeT =
| "companion_bff_ws_ext"
| "preset_list_ext"
| "filter_widgets_ext"
| "http_client_ext"
| "analytics_ext";
interface IExtensionMeta {
id: number; // Unique extension ID (ext_id)
name: string; // Extension name (e.g., "@invent/http-client-companion-ext")
version: string; // Version string (e.g., "1.0.0")
extensionVersionId: number; // Version record ID in database
author: string; // Extension author
scope: ExtensionScopeT; // Extension scope
module: string; // Module path for loading (e.g., "./Companion")
config: ConfigExtension; // Configuration object
}
type ConfigExtension = {
pathList: {
method: string; // HTTP method (GET, POST, etc.)
path: string; // API path
}[];
};
getExtension typing
getExtension is typed per scope via an overloaded signature, so the returned
instance is the correct I*Ext (or undefined) with no cast required. Unknown
scopes fall back to unknown.
interface GetExtensionT {
(scope: "analytics_ext"): IAnalyticsExt | undefined;
(scope: "companion_bff_ws_ext"): IBffWsExt | undefined;
(scope: "http_client_ext"): IHttpClientExt | undefined;
(scope: "preset_list_ext"): IPresetListExt | undefined;
(scope: "filter_widgets_ext"): IFilterWidgetsExt | undefined;
(scope: ExtensionScopeT): unknown;
}
The result is
… | undefinedbecause the extension may not be deployed for the current tenant, or may have failed to load. Always probe with?..
API Reference
GET /extensions/list
Returns extensions assigned to the current portal. Called by the portal on load.
Authentication: Border middleware (JWT from portal context)
Response:
[
{
"name": "@invent/http-client-companion-ext",
"id": 313,
"version": "1.0.0",
"module": "./Companion",
"scope": "http_client_ext",
"config": { "pathList": [] },
"author": "author@example.com",
"extensionVersionId": 5
}
]
Loading Mechanism
Extensions use Webpack Module Federation for dynamic loading at runtime.
Loading Flow
- Authentication - User logs in, token validated
- Fetch metadata - Portal calls
GET /extensions/list - Load scripts - For each extension, load
remoteEntry.js:- Development:
http://localhost:5{ext.id}/remoteEntry.js - Production:
${STORE_BASE_URL}/extensions/${ext.id}/${ext.version}/remoteEntry.js
- Development:
- Instantiate - Load companion class and create instance with metadata
Using Extensions
useExtensionsContext Hook
Access loaded extensions in components. getExtension is typed per scope, so no
cast is needed — but the result is … | undefined, so probe with ?.:
import { useExtensionsContext } from "@invent/platform-api";
const { getExtension, getExtensionsList } = useExtensionsContext();
// Typed per scope — no `as` cast needed
const httpClientExt = getExtension("http_client_ext"); // IHttpClientExt | undefined
const presetExt = getExtension("preset_list_ext"); // IPresetListExt | undefined
// Capability-bag scopes (e.g. preset_list_ext) expose optional members per
// tenant — probe each one. Narrow the deliberately-weak return at the call site.
const presets = presetExt?.presetsList?.({ widgets }) as ComposerWidgetT[];
// Report an analytics event from anywhere
getExtension("analytics_ext")?.track("filter_switch", { period: "Q" });
Host-side initialization (e.g.
analytics_ext.init(...)) is done once in the entry point afterareAllExtensionsLoaded— see theanalytics_extscope above.
Creating Extensions
Companion Class Structure
Extension companions must:
- Export a default class
- Accept
IExtensionMetain constructor - Implement the scope's contract (
I*Extfrom@invent/shared-types) so consumers get it typed throughgetExtension
import { IExtensionMeta } from "@invent/shared-types";
export default class HttpClientCompanion {
private meta: IExtensionMeta;
private httpClient: any;
constructor(meta: IExtensionMeta) {
this.meta = meta;
}
public init(httpClient: any): void {
this.httpClient = httpClient;
}
public async request(
path: string,
options: RequestOptions
): Promise<Response> {
// Implementation with project-specific configuration
}
}
Module Federation Config
Extension webpack configuration:
// webpack.config.js
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "http_client_ext", // Must match scope
filename: "remoteEntry.js",
exposes: {
"./Companion": "./src/companion.ts",
},
shared: {
// Shared dependencies
},
}),
],
};
extension-config.json
Each extension must have an extension-config.json file in the root directory:
{
"ext_id": 587,
"port": 5587,
"name": "@invent/http-client-companion-ext",
"issuer": "Invent",
"scope": "http_client_ext",
"url": "https://gitlab.apptrium.io/invent/frontend/co-project/http-client-co-ext",
"version": "1.1.0",
"default_branch": "master",
"module": "./HttpClientExt",
"config": { "pathList": [] }
}
Fields:
| Field | Description |
|---|---|
| ext_id | Unique extension identifier (gitlab repository id) |
| port | Local dev server port (convention: 5000 + ext_id) |
| name | NPM-style package name |
| issuer | Author/organization name |
| scope | Extension scope type |
| url | Git repository URL |
| version | Current version (semver) |
| default_branch | Git branch for releases |
| module | Exposed module name in webpack federation |
| config | Extension-specific configuration |
Extension Template
Use the template repository to create a new extension:
Template: https://gitlab.apptrium.io/invent/frontend/templates/http-client-extension-template
The template includes:
- Pre-configured webpack module federation setup
- Extension companion class structure
- CI/CD pipeline configuration
- Development server setup