npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@netsapiens/horizon-sdk

v0.1.10

Published

SDK for building remote applications that integrate with NetSapiens Horizon

Readme

@netsapiens/horizon-sdk

Build remote applications that load into NetSapiens Horizon at runtime via Module Federation.

This package is for third-party developers. It provides:

  • A typed HorizonContext you receive as props from the host
  • An RemoteAppSDK for registering routes, dynamic UI extensions, and table columns
  • React hooks that handle registration cleanup automatically
  • Re-styled MUI components so your app looks like Horizon

Internal engineering docs (event-bus contracts, module loader, security layers) live in src/sdk/README.md.


Install

npm install @netsapiens/horizon-sdk

Peer deps: react ^19, react-dom ^19, loglevel ^1.9.

Do not add @mui/material, @emotion/*, or @mui/x-data-grid-pro to your package.json or your webpack shared config. The host's federation loader does not expose them as shared modules — declaring them as singletons causes a version-mismatch crash at load time. Use all MUI components via horizonContext.ui instead; that is how theme and dark mode reach your app.


What's in horizonContext

Your App component receives this object as props; page components read it with useHorizonContext().

| Field | Notes | | ---------------- | ------------------------------------------------------------------------------------------------------- | | user | displayName, domain, email?, extension?, scope?, department?, site? (+ extras) | | auth | isAuthenticated(), plus the remote-auth methods (see Remote authentication) | | api | Authenticated NetSapiens v2 client (get/post/put/delete/getBaseUrl) | | theme | 'light' | 'dark' (reactive) | | locale | e.g. 'en-US' (reactive) | | t(key, opts?) | Host i18next translation function — all host strings are available, no setup needed | | navigate(path) | Pushes a route in the host router | | eventBus | emit/on/off for cross-app messages | | ui | Pre-themed components and templates (see below) | | breadcrumbs | Current breadcrumb trail (optional) |


Using horizonContext.ui

Don't ship MUI yourself — render Horizon's themed components instead. They inherit the host's MUI theme, including automatic dark/light mode switching.

Styling is handled by the SDK UI components. They inherit the host theme (dark mode included) and sensible layout defaults, so you rarely set styling yourself.

Renders are isolated. Each extension and side panel you render is wrapped in an error boundary by the host — a render error in your component shows a fallback instead of crashing the host page.

Page components read the context with useHorizonContext(); extension components receive it as a context prop:

// Page component — read the context with the hook
import { useHorizonContext } from "@netsapiens/horizon-sdk";

export default function MyPage() {
  const { ui, user } = useHorizonContext();
  const { PageTemplate, DatagridTemplate } = ui.templates;
  const { Button, Stack, Typography } = ui;

  return (
    <PageTemplate
      title="My Page"
      breadcrumbs={[{ label: "Apps", url: "/apps" }]}
    >
      <Stack spacing={2}>
        <Typography variant="h5">Hello, {user.displayName}</Typography>
        <Button variant="contained">Action</Button>
      </Stack>
    </PageTemplate>
  );
}
// Extension component — the host passes `context`
function MyButton({ context }: ExtensionComponentProps) {
  const { Button } = context.ui ?? {};
  return <Button variant="contained">Click</Button>;
}

Available under horizonContext.ui (or via useHorizonContext().ui):

  • templates: PageTemplate, PageTemplateWithExtensions, FormTemplate, SidePanel, FormPanel, DatagridTemplate

DatagridTemplate and the MUI X Pro licenseDatagridTemplate wraps MUI's DataGridPro internally, which is a paid licensed component. The license is held by the Horizon host; you do not need an MUI X Pro license and must not import @mui/x-data-grid-pro directly in your remote app. Always use DatagridTemplate from the SDK.

  • Form: Button, IconButton, TextField, Select, Checkbox, Radio, RadioGroup, Switch, ToggleButton, ToggleButtonGroup, FormLabel, FormControlLabel
  • Display: Typography, Chip, Avatar, Divider, Tooltip, Icon
  • Layout: Stack, Paper
  • Feedback: Alert
  • theme ('light' | 'dark', reactive via useHorizonContext()) and styles for derived tokens

IconButton — shorthand-only API

IconButton does not accept the standard MUI children pattern. It is a shorthand wrapper that takes the icon name as a string prop:

// ✅ Correct — pass the Iconify name via the `icon` prop
<IconButton icon="mdi:account" aria-label="Account" />
<IconButton icon="material-symbols:bolt" iconSize={18} size="small" onClick={handleClick} />

// ❌ Wrong — children are dropped, the button renders empty at 0×0
<IconButton aria-label="Account">
  <Icon icon="mdi:account" />
</IconButton>

The TypeScript types reject the children pattern at compile time. If you ever see a 0×0 button in the DOM, this is the first thing to check.

Other themed components (Button, Stack, Paper, etc.) follow the standard MUI children pattern.

SidePanel & FormPanel — right-side drawers

SidePanel and FormPanel are the shared right-drawer components Horizon uses for its own add/edit forms and detail panels. Reach for these when building a right-side drawer so it matches the host exactly and picks up the form-section-* extension zones automatically.

SidePanel — the drawer shell (sticky header, scrollable body, optional sticky footer). Use for detail/view/settings panels:

const { SidePanel } = ui.templates;

<SidePanel
  open={open}
  onClose={() => setOpen(false)}
  title="Details"
  subtitle="Read-only"
  width="lg" // 'sm' | 'md' | 'lg' | 'xl'
  footer={<Button onClick={() => setOpen(false)}>Close</Button>}
>
  {/* body */}
</SidePanel>;

FormPanelSidePanel + a <form>, a Submit/Cancel footer, and the form-section-before / form-section-after extension zones built in:

const { FormPanel } = ui.templates;

<FormPanel
  open={open}
  onClose={() => setOpen(false)}
  title="Add widget"
  formType="widget" // identifies the form to extensions
  mode="add" // 'add' | 'edit'
  formData={values} // live values handed to extensions
  onSubmit={(e) => {
    e?.preventDefault();
    save(values);
  }}
  submitLabel="Add"
  isSubmitting={pending}
  error={error}
>
  {/* field sections */}
</FormPanel>;

Multi-step wizard — pass steps instead of children; FormPanel renders the stepper header and a Back/Next/Submit footer. Each step's optional validate gates forward navigation (return boolean | Promise<boolean>):

<FormPanel
  open={open}
  onClose={() => setOpen(false)}
  title="Setup"
  formType="setup"
  mode="add"
  onSubmit={handleSubmit(onValid)}
  isComplete={isValid} // offer Submit before the last step
  steps={[
    {
      label: "Basics",
      content: <BasicsStep />,
      validate: () => trigger(["name"]),
    },
    { label: "Options", content: <OptionsStep /> },
  ]}
/>

A complete remote app in one file

// src/App.tsx
import { useEffect } from "react";
import { type HorizonContext, useRemoteApp } from "@netsapiens/horizon-sdk";
import MyButton from "./extensions/MyButton";

export default function App(horizonContext: HorizonContext) {
  // "myApp" === the name in your ModuleFederationPlugin config (webpack_module).
  // The SDK derives the kebab app id ("my-app") used for registry attribution.
  const { sdk, user, theme } = useRemoteApp(horizonContext, "myApp");

  useEffect(() => {
    sdk.registerDynamicExtension({
      id: "my-app.export-button",
      zone: "page-header-actions",
      routes: [{ pattern: "/manage/*/call-logs" }],
      component: MyButton,
    });
  }, [sdk]);

  return null; // Your App component never renders directly — it just registers.
}
// src/extensions/MyButton.tsx
import {
  type ExtensionComponentProps,
  useTheme,
} from "@netsapiens/horizon-sdk";

export default function MyButton({ context }: ExtensionComponentProps) {
  const { theme } = useTheme(context.eventBus); // reactive — no manual event wiring
  const { Button, Icon } = context.ui ?? {};
  if (!Button || !Icon) return null;
  return (
    <Button
      startIcon={<Icon icon="mdi:download" />}
      onClick={() => alert("Exported!")}
    >
      Export
    </Button>
  );
}

useRemoteApp returns an sdk instance that auto-cleans every registration when your app unmounts.


Registration APIs

Routes — full pages added to Horizon

Register a full page route using the SDK. Wrap your page component in HorizonContextProvider so it gets a live, reactive context — including automatic dark mode — without any extra wiring.

import {
  HorizonContextProvider,
  useHorizonContext,
} from "@netsapiens/horizon-sdk";
import MySettingsPage from "./pages/MySettingsPage";

// In your App component:
const MySettingsRoute = useMemo(
  () =>
    function MySettingsRoute() {
      return (
        <HorizonContextProvider context={horizonContext}>
          <MySettingsPage />
        </HorizonContextProvider>
      );
    },
  // eslint-disable-next-line react-hooks/exhaustive-deps
  [], // stable identity prevents React from unmounting the page on re-renders
);

sdk.registerRoute({
  id: "my-app.settings",
  parentPath: "/home", // attach under the My Account menu
  path: "my-settings", // → /home/my-settings
  label: "My Settings",
  icon: "mdi:cog",
  placement: { after: "settings" }, // position after the Settings item
  component: MySettingsRoute,
});

Inside the page component, read context with useHorizonContext():

// src/pages/MySettingsPage.tsx
import { useHorizonContext } from "@netsapiens/horizon-sdk";

export default function MySettingsPage() {
  const { ui, user, navigate } = useHorizonContext();
  const { PageTemplate } = ui.templates;
  const { Button, Stack, Typography } = ui;

  return (
    <PageTemplate
      title="Settings"
      breadcrumbs={[{ label: "Apps", url: "/apps" }]}
    >
      <Stack spacing={2}>
        <Typography variant="body1">Hello, {user.displayName}.</Typography>
        <Button variant="contained" onClick={() => navigate("/apps")}>
          Back
        </Button>
      </Stack>
    </PageTemplate>
  );
}

The useRoute hook handles register + unregister automatically:

useRoute(horizonContext.eventBus, 'myApp', { id: '...', /* ... */, component: MySettingsRoute });

If your route's page component is exposed as a separate Module Federation module in your own bundle (rather than imported into your entry App), use useRouteFromModule — it loads that exposed module from your container and registers it as a route. (This is for splitting up your own app's code; it does not pull a component from another app.)

const { loading, error } = useRouteFromModule(
  horizonContext.eventBus,
  "myApp",
  {
    id: "my-app.settings",
    parentPath: "/home",
    path: "my-settings",
    label: "My Settings",
  },
  { scope: "myApp", module: "./SettingsPage" }, // an exposed module in YOUR webpack config
);

Menu placement

Use placement to control where your route appears in the menu, relative to existing menu items:

  • { after: 'anchor-id' } — Place immediately after the specified item
  • { before: 'anchor-id' } — Place immediately before the specified item
  • { first: true } — Force to the start of the menu
  • { last: true } — Force to the end of the menu (default if no placement specified)

The anchor is the human-readable id/label of an existing item (e.g. 'contacts', 'devices', 'settings'). The host resolves it with fuzzy matching, so minor differences and typos (e.g. contats) still match. If no anchor matches, your item is placed at the end of the menu with a console warning.

Dynamic extensions — inject UI into host pages

Extensions render at named zones on routes that match a pattern. You don't need the host page to opt in.

sdk.registerDynamicExtension({
  id: "my-app.row-action",
  zone: "table-row-actions",
  routes: [{ pattern: "/manage/*/call-logs" }],
  priority: 10, // higher = first
  requiredPermissions: ["calls:read"],
  condition: (ctx) => ctx.user.domain === "special.com",
  component: RowAction,
});

Available zones (the zones the host mounts today):

| Zone | Where it renders | | ----------------------- | ---------------------------------------------------------------------------------- | | page-header-actions | Right side of any page header | | page-header-secondary | Subtitle row of the page header (badges, status) | | page-content-after | Below the main page content | | topbar-actions | Global top app bar (after comms buttons, before utilities) | | table-toolbar | Toolbar above any DataGrid | | table-filter-bar | Filter chips inline with the search bar; pass a row predicate via onFilterChange | | table-row-actions | Per-row action column | | form-section-before | Above a form's field sections (SidePanel/FormPanel) | | form-section-after | Below a form's fields, before the action buttons | | inbound-call-content | Incoming-call notification body |

Note: Check Platform → UI SDK Management → SDK Settings for the live list of zones (a platform admin can disable individual zones).

Custom zones (any string) work for pages that explicitly render <DynamicExtensionRenderer zone="my-zone" />.

Route patterns support * (any segment) and :name (named param):

{ pattern: '/manage/call-logs' }                    // exact-ish
{ pattern: '/manage/*/call-logs' }                  // any domain
{ pattern: '/manage/:domain/users', exact: true }   // exact match, params extracted
{ pattern: '/*' }                                   // every route

Hook form:

useDynamicExtension(horizonContext.eventBus, "myApp", {
  id: "my-app.banner",
  zone: "page-content-after",
  routes: [{ pattern: "/manage/:domain/users" }],
  component: Banner,
});

table-filter-bar zone

Pages that render a filter bar expose a TableFilterBarContext via context.pageContext. Extensions in this zone render alongside the host's built-in filter chips and can drive row-level filtering by passing a predicate function to onFilterChange. The host applies the function generically — your extension owns the filtering logic, not the host.

import { useState } from "react";
import type {
  ExtensionComponentProps,
  TableFilterBarContext,
} from "@netsapiens/horizon-sdk";

export function VipFilter({ context }: ExtensionComponentProps) {
  const filterCtx = context.pageContext as TableFilterBarContext | undefined;
  const { ToggleButtonGroup } = context.ui ?? {};
  const [active, setActive] = useState(false);

  function handleChange(_e: React.SyntheticEvent, value: string | null) {
    const next = value === "vip";
    setActive(next);
    if (next) {
      filterCtx?.onFilterChange(
        (row) => (row as Record<string, unknown>)["customer-tier"] === "vip",
      );
    } else {
      filterCtx?.onFilterChange(null); // clear — show all rows
    }
  }

  if (!ToggleButtonGroup) return null;

  return (
    <ToggleButtonGroup
      value={active ? "vip" : null}
      exclusive
      onChange={handleChange}
      options={[{ value: "vip", label: "VIP Only" }]}
    />
  );
}

TableFilterBarContext fields:

| Field | Type | Description | | ---------------- | --------------------------------------------------------- | ----------------------------------------------------- | | onFilterChange | (filterFn: ((row: unknown) => boolean) \| null) => void | Pass a predicate to filter rows; pass null to clear |

Important: track active/inactive state locally with useStateTableFilterBarContext does not expose the current filter state back to you.

React useState gotcha — never pass a filter function directly to setState:

setMyFilter(filterFn); // ❌ React treats functions as lazy initializers, calling fn(prev)
setMyFilter(() => filterFn); // ✅ Wrap in an arrow so React stores the function itself

This only affects you if you store the filter function in your own component state. The host DataTable handles this correctly internally.

Dynamic table columns

Add columns to host DataGrids that opt in (zone names like call-logs-columns, users-columns, etc.):

sdk.registerDynamicColumn({
  id: "my-app.priority",
  zone: "call-logs-columns",
  routes: [{ pattern: "/manage/*/call-logs" }],
  column: {
    field: "priority",
    headerName: "Priority",
    width: 120,
    sortable: true,
    filterable: true,
    renderCell: ({ row }) => <PriorityCell row={row} />,
    valueGetter: (_v, row) => computePriority(row),
  },
});

Hook: useDynamicColumn(eventBus, appId, config).

Registered columns right-align by default (header and cell) to match Horizon's native data-table columns — you don't need to set align. Override per column with align/headerAlign: 'left' | 'center'.


Dark mode

Dark mode is handled automatically — you write no theme-switching code for MUI components.

MUI components (Button, Paper, Stack, DatagridTemplate, etc.) inherit the host's ThemeProvider via the shared module singleton and switch the moment the user toggles the theme. For any conditional logic that reads the theme string (custom colors, conditional icons, etc.), use useTheme():

import { useTheme } from "@netsapiens/horizon-sdk";

useTheme() is a single hook that works identically in both contexts:

| Where you use it | How it works | | ------------------------------------------------------------------- | ----------------------------------------------------------------------- | | Page component (inside HorizonContextProvider) | Reads directly from provider React context — zero subscription overhead | | Extension component (registered via registerDynamicExtension) | Pass context.eventBus; hook subscribes and unsubscribes automatically |

// Page component
const { theme } = useTheme(); // no argument needed inside HorizonContextProvider

// Extension component
const { theme } = useTheme(context.eventBus); // pass eventBus for extensions

Do not subscribe to theme:changed on the event bus manually. useTheme() handles the subscription and cleanup internally.


API client

const { api, user } = horizonContext;
const devices = await api.get(
  `/domains/${user.domain}/users/${user.extension}/devices`,
);
await api.post(`/domains/${user.domain}/users/${user.extension}/contacts`, {
  "name-first-name": "John",
});

All calls run through Horizon's audited proxy — credentials never reach the remote app.


Remote authentication

When your app needs to call your own backend (or a third-party vendor) on behalf of the signed-in user, have the platform relay a trusted identity handshake. Your server proves a request genuinely represents the current Horizon user — without your app ever handling Horizon credentials.

const { auth } = horizonContext;

const token = await auth.requestRemoteAuth({
  vendorId: "my-backend", // your system/vendor id
  callbackUrl: "https://api.example.com/horizon/callback", // your backend webhook
  scopes: ["contacts:read"], // optional
});
// token: RemoteAuthResponse — { vendorId, accessToken, tokenType?, expiresAt?, refreshToken? }

Flow:

  1. Your app calls auth.requestRemoteAuth(...) and awaits the promise.
  2. The platform binds the request to the signed-in user's trusted session (never a user id from the request body), checks the app has remote auth enabled and the callbackUrl hostname is allow-listed, then POSTs a single-use authorization code to your callbackUrl.
  3. Your backend verifies the webhook, exchanges the code, and mints its own token (see below).
  4. The promise resolves with a RemoteAuthResponse (your token), cached for the session; it rejects with a RemoteAuthError on failure or timeout.

What your backend receives

A POST to your callbackUrl with headers X-NS-Request-ID, X-NS-Signature (sha256=<hmac>), and X-NS-Cluster-Verification (a signed JWT), and a JSON body:

{
  "request_id": "…",
  "code": "…",
  "code_verifier": "…",
  "user": { "uid": "…", "domain": "…", "displayName": "…" },
  "expires_in": 600,
  "validation_endpoint": "https://…/oauth2/token",
  "timestamp": 1716720479
}

What your backend must do

  1. Verify authenticity. Recompute HMAC-SHA256(request_id + code + timestamp) with your app's shared callback secret and compare it (constant-time) against X-NS-Signature. For defense in depth, also verify the X-NS-Cluster-Verification JWT against the platform's published JWKS.
  2. Exchange the code. POST the validation_endpoint (form-encoded: grant_type=authorization_code, code, code_verifier, username=user.uid) to receive a token that proves the user's identity.
  3. Mint and return your own token as OAuth { access_token, token_type, expires_in, refresh_token? }. The platform maps these onto RemoteAuthResponse (access_token → accessToken, expires_in → expiresAt as an absolute Unix time, etc.); fields outside that set are not forwarded.

A runnable reference backend (Node/Express) lives in the demo app: examples/vendor-backend.

Retrieve or clear a cached token later:

const cached = auth.getRemoteAuthToken("my-backend"); // RemoteAuthResponse | null
auth.clearRemoteAuthToken("my-backend"); // sign out of the vendor

Admin setup (per app, in Registered Apps): enable remote auth, list the allowed callback hostname(s), and set the callback signing secret your backend uses to verify X-NS-Signature.


Event bus

The event bus carries everything that crosses the host/remote boundary. Most of it you should not touch directly — the SDK wraps the parts you need in gated, app-scoped APIs. Reach for eventBus raw only for your own custom, app-namespaced messages.

Don't listen to host data streams directly

Platform data streams — call events, presence, device registration, notifications — are not yours to subscribe to off the raw bus. Doing so is neither capability-gated nor attributed to your app, so it bypasses the permission the platform admin grants per app, and it will not light up the Capabilities column on the Registered Apps page.

// ❌ Wrong — ungated, unattributed, and not delivered: host data streams
// never reach the raw bus, so this handler fires for nothing.
horizonContext.eventBus.on("call-event", handler);

For call events, use the gated subscription — it enforces the call-events:listen capability and records the subscription against your app:

useEffect(() => {
  const unsubscribe = sdk.subscribeToCallEvents(
    ["call-started", "call-answered", "call-ended"],
    (event) => console.log("call event", event),
  );
  return unsubscribe; // also torn down by sdk.cleanup()
}, [sdk]);

Other host data streams

Other host feeds are subscribed the same gated way, via sdk.subscribeToStream(streamId, eventTypes, callback). Each stream is gated by a <streamId>:listen capability the platform grants your app, and the subscription is attributed to you (so it shows on the Registered Apps Capabilities column). The returned function unsubscribes; sdk.cleanup() also tears it down.

useEffect(() => {
  const unsubscribe = sdk.subscribeToStream(
    "subscriber",
    ["update"],
    (event) => {
      console.log("subscriber event", event);
    },
  );
  return unsubscribe;
}, [sdk]);

Available streams (StreamId):

| Stream ID | Capability | Carries | | -------------- | --------------------- | ------------------------------------------------------------- | | call-events | call-events:listen | SIP call lifecycle — prefer the typed subscribeToCallEvents | | subscriber | subscriber:listen | subscriber / user record changes | | device | device:listen | device state | | registration | registration:listen | SIP registration state |

The useStream(eventBus, webpackModule, streamId, eventTypes, callback) hook does the same with automatic cleanup. If you need a feed that isn't listed, request it through your Horizon admin — don't reach for a raw channel name (host streams are not delivered on the raw bus).

Your own custom events

For messages your app emits and consumes within itself — e.g. an extension (a table row action) signalling your app's page or side panel — use the bus for your own custom events:

useEffect(() => {
  const handler = (data: unknown) => console.log("my-app event", data);
  horizonContext.eventBus.on("my-app:refresh", handler);
  return () => horizonContext.eventBus.off("my-app:refresh", handler);
}, []);

The host hands each app a scoped event bus, so your custom events are automatically confined to your app — they reach your own pages and extensions but never another app, and attempts to listen on host channels or another app's events are blocked. (Prefixing with your appId, e.g. my-app:, is still good practice for clarity.) Opening the shared side panel from an extension is a built-in control event — use useSidePanel(), not a custom event.

There is no generic useEvent hook. The SDK wraps the specific host events it manages for you: useTheme() (theme changes), useLocale() (locale changes), subscribeToCallEvents() (call events), and useSidePanel(). In particular, do not subscribe to theme:changed or call-event yourself — use useTheme() and subscribeToCallEvents() respectively.


Hosting requirements

Your app is delivered as a static bundle served over HTTPS. Before you can register it you need a stable, publicly reachable URL for remoteEntry.js.

SSL — HTTPS is required in production. Browsers block mixed-content requests, so an HTTP-only endpoint will not load inside an HTTPS Horizon instance. Localhost is the only allowed exception during local development.

CDN / static hosting — Any CDN or static file host works (AWS S3 + CloudFront, Cloudflare Pages, Azure Blob, your own nginx). The URL must remain stable across deploys; if you use content-hashed filenames for assets, the remoteEntry.js itself should stay at a fixed path (e.g. https://cdn.example.com/my-app/remoteEntry.js).

CORS — The Horizon host makes a cross-origin request to fetch your remoteEntry.js. Your server must respond with:

Access-Control-Allow-Origin: https://your-horizon-instance.fqdn

During local development the webpack dev server sets Access-Control-Allow-Origin: * (any origin) so you can test against any Horizon instance. Tighten this to the specific Horizon domain before deploying to production — * in production means any website could load your bundle.

Domain whitelist — In addition to CORS, your CDN domain must be approved by the Horizon administrator. See Registering with Horizon below for how to request approval.


Build configuration

Use webpack Module Federation. Minimal webpack.config.js:

const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");

module.exports = (_env, argv) => ({
  entry: "./src/App.tsx",
  output: { publicPath: "auto", filename: "remoteEntry.js", clean: true },
  resolve: { extensions: [".tsx", ".ts", ".js"] },
  module: {
    rules: [
      { test: /\.(tsx?|jsx?)$/, exclude: /node_modules/, use: "babel-loader" },
    ],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "myApp", // your Extension App ID — must match exactly what you enter during registration
      filename: "remoteEntry.js",
      exposes: { "./App": "./src/App" },
      shared: {
        react: { singleton: true, requiredVersion: "^19.0.0" },
        "react-dom": { singleton: true, requiredVersion: "^19.0.0" },
        loglevel: { singleton: true, requiredVersion: "^1.9.0" },
        "@netsapiens/horizon-sdk": {
          singleton: true,
          requiredVersion: "^0.1.0", // match the @netsapiens/horizon-sdk version you installed
        },

        // Do NOT add @mui/material, @emotion/*, or @mui/x-data-grid-pro here.
        // The host's federation loader does not register MUI as a shared module,
        // so declaring it as a singleton causes an "Unsatisfied version" crash.
        //
        // Instead, consume all MUI components via horizonContext.ui — this is
        // what gives your components the theme and dark mode automatically.
      },
    }),
  ],
  devServer: { port: 5005, headers: { "Access-Control-Allow-Origin": "*" } },
});

Your app must expose ./App as the default export accepting HorizonContext as props. Page components rendered via registered routes should use useHorizonContext() internally — do not thread horizonContext as a prop through every component.


Registering with Horizon

Once your remoteEntry.js is hosted and reachable, register it through the Horizon admin UI at Platform → UI SDK Management → Registered Apps → Add App, or via the API:

curl -X POST "https://your-horizon-instance.com/ns-api/v2/ui-extensions" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My App",
    "description": "Short description shown in the app list",
    "version": "1.0.0",
    "remote_entry_url": "https://cdn.example.com/my-app/remoteEntry.js",
    "webpack_module": "myApp",
    "author": "Acme Corp",
    "enabled": true
  }'

Registration fields

| Field | Required | Notes | | -------------------- | -------- | --------------------------------------------------------------------------------------------- | | Name | ✓ | Display name shown in the navigation menu and admin UI | | Description | | Brief summary shown as a subtitle in the app list | | Version | ✓ | Semantic version (e.g. 1.0.0). Used for display and auditing only — does not affect caching | | Remote Entry URL | ✓ | Full HTTPS URL to your remoteEntry.js. See Hosting requirements above | | Extension App ID | ✓ | See below | | Author | | Individual or company name. Shown in app details | | Enabled | | Defaults to true. Set to false to register without immediately activating |

Extension App ID

The Extension App ID (the API field webpack_module) is the single most important field to get right. It must exactly match the name value in your webpack ModuleFederationPlugin config:

// webpack.config.js
new ModuleFederationPlugin({
  name: 'myUcaasApp',   // ← this is your Extension App ID
  ...
})
// Registration
{ "webpack_module": "myUcaasApp" }

Why it matters: At runtime, the platform loads your bundle and calls window['myUcaasApp'].init(...) to initialise the federation container. If the name doesn't match exactly — including case — the container won't be found and your app silently fails to load with no visible error.

One identifier, everywhere. webpack_module is the only id you maintain. You pass the same value to useRemoteApp(horizonContext, "myUcaasApp"), and the SDK, host, and API all derive the kebab-case registry id (my-ucaas-app) from it the same way — that derived id is the registration's primary key, the /ui-extensions/{id} path, and the attribution id for your extensions. (Need it explicitly? import { deriveAppId } from "@netsapiens/horizon-sdk".)

Rules:

  • Must be a valid JavaScript identifier — letters, digits, _, $; no dashes or spaces, can't start with a digit. webpack's default container library declares it as a var, so a dash (e.g. my-app) fails the build with "name … must be a valid identifier." camelCase is the convention (e.g. myUcaasApp, not my-ucaas-app or MyUcaasApp).
  • Must be unique platform-wide. The API enforces this with a database unique constraint and returns HTTP 409 Conflict if the webpack_module is already taken — on both register and update. Pick a distinctive, namespaced value (e.g. acmeCrmSync, not crm).
  • Immutable once registered — it's baked into your built remoteEntry.js as the container global, so changing it means rebuilding and re-registering.

Domain whitelist

Your CDN domain must be approved by the Horizon administrator before the platform will load your app. Contact your admin and provide:

  • Your CDN domain or pattern (e.g. cdn.example.com or *.example.com)
  • Whether you need localhost approved for local development

If the domain is not approved, your app will silently fail to load. Check the browser console for [HorizonAppsLoader] error messages containing your URL to confirm this is the cause.

For local development: Ask your admin to temporarily add localhost:5005 (or whatever port your dev server uses) to the approved list.

Verifying your app loaded

After registering and refreshing the browser:

  1. Open DevTools → Console and look for log lines from your app — your App.tsx runs on page load and any console.log calls appear here
  2. Check the network tab — filter by your domain and look for remoteEntry.js. A 200 confirms the bundle was fetched; a CORS error or net::ERR_* means the domain isn't reachable or CORS headers are missing
  3. Look for your registered routes — if you called sdk.registerRoute, your page should appear in the navigation sidebar immediately after the app loads
  4. Look for your extensions — visit the route pattern your extension targets (e.g. /manage/call-logs) and confirm the injected component appears
  5. Check the Registered Apps page (Platform → UI SDK Management → Registered Apps) — the Extension Zones column shows which zones your app has active extensions in once it has loaded

If nothing appears after a full page refresh:

  • Confirm the Enabled toggle is on in the admin UI
  • Confirm your CDN domain is approved (see above)
  • Check for JavaScript errors in the console — a crash in your App.tsx before sdk.registerRoute is called means nothing gets registered

Live QA toggle: from the browser console, window.__horizonSDKDebug__ exposes session-local debug helpers — handy for isolating whether an issue comes from a remote app:

window.__horizonSDKDebug__.help(); // list all commands
window.__horizonSDKDebug__.disableExtensionApps(); // reload with ALL remote apps off (this session only)
window.__horizonSDKDebug__.enableExtensionApps(); // re-enable and reload
window.__horizonSDKDebug__.enable(); // turn on verbose SDK logging

The disable state is session-local (persisted in localStorage), so it only affects your own browser.


After registration

Timing: The app list is cached for up to 5 minutes. After registering, wait up to 5 minutes or do a hard refresh (Ctrl+Shift+R / Cmd+Shift+R) before expecting other users to see your app. In development mode, the cache is automatically bypassed.

Per-session load: Your app's App.tsx mounts once per browser session inside a hidden container. It runs for the lifetime of the session, keeping all registrations active. Navigating between pages does not reload the app.

Enabling and disabling: Toggling the Enabled switch takes effect immediately for new sessions — the running app list is refreshed in the background. Users in active sessions see the change on their next page load.


Managing your app

Updating a deployed app

Deploying a new build to the same Remote Entry URL is the simplest strategy — the platform fetches whatever remoteEntry.js is at that URL on each session start. Update the Version field in the registration to record the change for auditing.

If you use version-specific URLs (e.g. cdn.example.com/my-app/v1.2.0/remoteEntry.js), update the Remote Entry URL in the registration when you release a new version.

Active sessions continue using the version that loaded when they started. Users see the new version on their next page refresh.

Integrity (SRI) checking — opt-in. You may register an integrity_hash (Subresource Integrity hash of your remoteEntry.js, encoded as sha384-<base64>). When present, the host verifies the fetched bundle against it and refuses to load on mismatch; when absent, the check is skipped. It is opt-in today and not yet enforced by the platform.

Generate the hash from your build so it always matches the emitted bundle, e.g.:

echo "sha384-$(openssl dgst -sha384 -binary dist/remoteEntry.js | openssl base64 -A)"

Submit that value as integrity_hash on register/update. When you redeploy, regenerate it for the new bundle and update the registration — a stale hash makes the app fail the integrity check and refuse to load.

Disable vs. Delete

| Action | Effect | Reversible? | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------- | | Disable | App stops loading for new sessions. Extensions and routes are immediately unregistered for all active users. Database record is preserved. | ✓ Re-enable at any time | | Delete | Permanently removes the registration. Cannot be undone. | ✗ |

Use Disable when you need to take an app offline temporarily (maintenance, incident response). Use Delete only when decommissioning an app permanently.


Cleanup & lifecycle

useRemoteApp calls sdk.cleanup() on unmount, which emits unregister events for every route, dynamic extension, and dynamic column the SDK tracked. If you bypass the hook, call sdk.cleanup() yourself in your unmount path.


Errors

Throw and catch typed errors with codes:

import { HorizonSDKError, apiError } from "@netsapiens/horizon-sdk";

try {
  await api.get("/...");
} catch (err) {
  if (HorizonSDKError.isHorizonSDKError(err)) {
    console.error(err.code, err.getUserMessage());
  }
}

Codes: PERMISSION_DENIED, RATE_LIMIT_EXCEEDED, API_ERROR, NETWORK_ERROR, MODULE_LOAD_FAILED, INITIALIZATION_FAILED, etc.


Migration

The SDK is pre-release (0.x) and not yet published, so no released version is affected — but if you built against an earlier working copy, note these breaking changes:

  • Registration field renamed: module_scopewebpack_module. The /ui-extensions API request and response now use webpack_module. Re-register or update your app with the new field name; the value itself (your ModuleFederationPlugin name) is unchanged. See Extension App ID.
  • webpack_module must be unique platform-wide. Registration now fails with HTTP 409 Conflict if the value is already taken (register and update). Choose a distinctive, namespaced id.
  • React 19 only. The react / react-dom peer dependency is now ^19 (was ^18 || ^19). These are greenfield apps — target React 19.
  • Anchor constants removed. MANAGE_ANCHORS / PLATFORM_ANCHORS / APPS_ANCHORS / MY_ACCOUNT_ANCHORS / ANCHORS and the AnchorId type are gone. Pass placement targets as plain strings (e.g. placement: { after: "contacts" }) — the host resolves them with fuzzy matching. See Menu placement.
  • integrity_hash (opt-in, additive). You may now submit an SRI hash (sha384-<base64>) on registration. See Updating a deployed app.

License

MIT