@netsapiens/horizon-sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +643 -0
- package/dist/index.cjs +486 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +861 -0
- package/dist/index.d.ts +861 -0
- package/dist/index.js +453 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -0
package/README.md
ADDED
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
# @netsapiens/horizon-sdk
|
|
2
|
+
|
|
3
|
+
Build remote applications that load into NetSapiens Horizon at runtime via Module Federation.
|
|
4
|
+
|
|
5
|
+
This package is for **third-party developers**. It provides:
|
|
6
|
+
|
|
7
|
+
- A typed `HorizonContext` you receive as props from the host
|
|
8
|
+
- An `RemoteAppSDK` for registering routes, dynamic UI extensions, and table columns
|
|
9
|
+
- React hooks that handle registration cleanup automatically
|
|
10
|
+
- Re-styled MUI components so your app looks like Horizon
|
|
11
|
+
|
|
12
|
+
> Internal engineering docs (event-bus contracts, module loader, security layers) live in [`src/sdk/README.md`](../src/sdk/README.md).
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @netsapiens/horizon-sdk
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Peer deps: `react ^18 || ^19`, `react-dom ^18 || ^19`, `loglevel ^1.9`.
|
|
23
|
+
|
|
24
|
+
> 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 Aurora theme and dark mode reach your app.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## A complete remote app in one file
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
// src/App.tsx
|
|
32
|
+
import { useEffect } from 'react';
|
|
33
|
+
import { type HorizonContext, useRemoteApp } from '@netsapiens/horizon-sdk';
|
|
34
|
+
import MyButton from './extensions/MyButton';
|
|
35
|
+
|
|
36
|
+
export default function App(horizonContext: HorizonContext) {
|
|
37
|
+
const { sdk, user, theme } = useRemoteApp(horizonContext, 'my-app');
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
sdk.registerDynamicExtension({
|
|
41
|
+
id: 'my-app.export-button',
|
|
42
|
+
zone: 'page-header-actions',
|
|
43
|
+
routes: [{ pattern: '/manage/*/call-logs' }],
|
|
44
|
+
component: MyButton,
|
|
45
|
+
});
|
|
46
|
+
}, [sdk]);
|
|
47
|
+
|
|
48
|
+
return null; // Your App component never renders directly — it just registers.
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
// src/extensions/MyButton.tsx
|
|
54
|
+
import { type ExtensionComponentProps, useTheme } from '@netsapiens/horizon-sdk';
|
|
55
|
+
|
|
56
|
+
export default function MyButton({ context }: ExtensionComponentProps) {
|
|
57
|
+
const { theme } = useTheme(context.eventBus); // reactive — no manual event wiring
|
|
58
|
+
const { Button, Icon } = context.ui ?? {};
|
|
59
|
+
if (!Button || !Icon) return null;
|
|
60
|
+
return (
|
|
61
|
+
<Button startIcon={<Icon icon="mdi:download" />} onClick={() => alert('Exported!')}>
|
|
62
|
+
Export
|
|
63
|
+
</Button>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`useRemoteApp` returns an `sdk` instance that auto-cleans every registration when your app unmounts.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## What's in `horizonContext`
|
|
73
|
+
|
|
74
|
+
| Field | Notes |
|
|
75
|
+
| ---------------- | ----------------------------------------------------------------------------- |
|
|
76
|
+
| `user` | `displayName`, `domain`, `email?`, plus host-defined extras |
|
|
77
|
+
| `auth` | `isAuthenticated()` |
|
|
78
|
+
| `api` | Authenticated NetSapiens v2 client (`get`/`post`/`put`/`delete`/`getBaseUrl`) |
|
|
79
|
+
| `theme` | `'light'` \| `'dark'` |
|
|
80
|
+
| `locale` | e.g. `'en-US'` |
|
|
81
|
+
| `navigate(path)` | Pushes a route in the host router |
|
|
82
|
+
| `eventBus` | `emit`/`on`/`off` for cross-app messages |
|
|
83
|
+
| `ui` | Pre-themed components and templates (see below) |
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Registration APIs
|
|
88
|
+
|
|
89
|
+
### Routes — full pages added to Horizon
|
|
90
|
+
|
|
91
|
+
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.
|
|
92
|
+
|
|
93
|
+
```tsx
|
|
94
|
+
import { HorizonContextProvider, useHorizonContext } from '@netsapiens/horizon-sdk';
|
|
95
|
+
import MySettingsPage from './pages/MySettingsPage';
|
|
96
|
+
|
|
97
|
+
// In your App component:
|
|
98
|
+
const MySettingsRoute = useMemo(
|
|
99
|
+
() =>
|
|
100
|
+
function MySettingsRoute() {
|
|
101
|
+
return (
|
|
102
|
+
<HorizonContextProvider context={horizonContext}>
|
|
103
|
+
<MySettingsPage />
|
|
104
|
+
</HorizonContextProvider>
|
|
105
|
+
);
|
|
106
|
+
},
|
|
107
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
108
|
+
[], // stable identity prevents React from unmounting the page on re-renders
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
sdk.registerRoute({
|
|
112
|
+
id: 'my-app.settings',
|
|
113
|
+
parentPath: '/home', // attach under Apps menu
|
|
114
|
+
path: 'my-settings', // → /home/my-settings
|
|
115
|
+
label: 'My Settings',
|
|
116
|
+
icon: 'mdi:cog',
|
|
117
|
+
placement: { after: 'settings' }, // position after the Settings item
|
|
118
|
+
component: MySettingsRoute,
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
#### Menu placement
|
|
123
|
+
|
|
124
|
+
Use `placement` to control where your route appears in the menu. You can position relative to existing menu items using semantic anchors:
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
sdk.registerRoute({
|
|
128
|
+
id: 'my-app.settings',
|
|
129
|
+
parentPath: '/home',
|
|
130
|
+
path: 'my-settings',
|
|
131
|
+
label: 'My Settings',
|
|
132
|
+
icon: 'mdi:cog',
|
|
133
|
+
placement: { after: 'settings' }, // After the Settings item
|
|
134
|
+
component: MySettingsRoute,
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
**Placement options:**
|
|
139
|
+
|
|
140
|
+
- `{ after: 'anchor-id' }` — Place immediately after the specified item
|
|
141
|
+
- `{ before: 'anchor-id' }` — Place immediately before the specified item
|
|
142
|
+
- `{ first: true }` — Force to the start of the menu
|
|
143
|
+
- `{ last: true }` — Force to the end of the menu (default if no placement specified)
|
|
144
|
+
|
|
145
|
+
The system uses fuzzy matching with a 0.8 similarity threshold, so minor typos (e.g., `contats` instead of `contacts`) will still work. If the anchor isn't found, your item is placed at the end of the menu with a console warning.
|
|
146
|
+
|
|
147
|
+
Inside the page component, read context with `useHorizonContext()` instead of accepting it as props:
|
|
148
|
+
|
|
149
|
+
```tsx
|
|
150
|
+
// src/pages/MySettingsPage.tsx
|
|
151
|
+
import { useHorizonContext } from '@netsapiens/horizon-sdk';
|
|
152
|
+
|
|
153
|
+
export default function MySettingsPage() {
|
|
154
|
+
const { ui, user, navigate, theme } = useHorizonContext();
|
|
155
|
+
const { PageTemplate } = ui.templates;
|
|
156
|
+
const { Button, Stack, Typography } = ui;
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<PageTemplate title="Settings" breadcrumbs={[{ label: 'Apps', url: '/apps' }]}>
|
|
160
|
+
<Stack spacing={2}>
|
|
161
|
+
<Typography variant="body1">Hello, {user.displayName}.</Typography>
|
|
162
|
+
<Button variant="contained" onClick={() => navigate('/apps')}>
|
|
163
|
+
Back
|
|
164
|
+
</Button>
|
|
165
|
+
</Stack>
|
|
166
|
+
</PageTemplate>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The `useRoute` hook handles register + unregister automatically:
|
|
172
|
+
|
|
173
|
+
```tsx
|
|
174
|
+
useRoute(horizonContext.eventBus, 'my-app', { id: '...', /* ... */, component: MySettingsRoute });
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
If your route component lives in a separate exposed module, use `useRouteFromModule`:
|
|
178
|
+
|
|
179
|
+
```tsx
|
|
180
|
+
const { loading, error } = useRouteFromModule(
|
|
181
|
+
horizonContext.eventBus,
|
|
182
|
+
'my-app',
|
|
183
|
+
{ id: 'my-app.settings', parentPath: '/home', path: 'my-settings', label: 'My Settings' },
|
|
184
|
+
{ scope: 'myApp', module: './SettingsPage' },
|
|
185
|
+
);
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Dynamic extensions — inject UI into host pages
|
|
189
|
+
|
|
190
|
+
Extensions render at named **zones** on routes that match a **pattern**. You don't need the host page to opt in.
|
|
191
|
+
|
|
192
|
+
```tsx
|
|
193
|
+
sdk.registerDynamicExtension({
|
|
194
|
+
id: 'my-app.row-action',
|
|
195
|
+
zone: 'table-row-actions',
|
|
196
|
+
routes: [{ pattern: '/manage/*/call-logs' }],
|
|
197
|
+
priority: 10, // higher = first
|
|
198
|
+
requiredPermissions: ['calls:read'],
|
|
199
|
+
condition: (ctx) => ctx.user.domain === 'special.com',
|
|
200
|
+
component: RowAction,
|
|
201
|
+
});
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**Available zones:**
|
|
205
|
+
|
|
206
|
+
| Zone | Where it renders |
|
|
207
|
+
| -------------------------- | -------------------------------------------- |
|
|
208
|
+
| `page-header-actions` | Right side of any page header |
|
|
209
|
+
| `page-header-secondary` | Subtitle row of page header (badges, status) |
|
|
210
|
+
| `page-content-before` | Above main page content |
|
|
211
|
+
| `page-content-after` | Below main page content |
|
|
212
|
+
| `page-sidebar` | Page sidebar |
|
|
213
|
+
| `table-toolbar` | Above any DataGrid |
|
|
214
|
+
| `table-row-actions` | Per-row action column |
|
|
215
|
+
| `detail-panel-tabs` | Detail-panel tab strip |
|
|
216
|
+
| `detail-panel-actions` | Detail-panel action area |
|
|
217
|
+
| `topbar-actions` | Global top app bar (after comms, before utilities) |
|
|
218
|
+
| `inbound-call-content` | Incoming-call notification body |
|
|
219
|
+
|
|
220
|
+
> **Note**: Check Platform → UI SDK Management → SDK Settings for the current list of available zones and their descriptions.
|
|
221
|
+
|
|
222
|
+
Custom zones (any string) work for pages that explicitly render `<DynamicExtensionRenderer zone="my-zone" />`.
|
|
223
|
+
|
|
224
|
+
**Route patterns** support `*` (any segment) and `:name` (named param):
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
{ pattern: '/manage/call-logs' } // exact-ish
|
|
228
|
+
{ pattern: '/manage/*/call-logs' } // any domain
|
|
229
|
+
{ pattern: '/manage/:domain/users', exact: true } // exact match, params extracted
|
|
230
|
+
{ pattern: '/*' } // every route
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Hook form:
|
|
234
|
+
|
|
235
|
+
```tsx
|
|
236
|
+
useDynamicExtension(horizonContext.eventBus, 'my-app', {
|
|
237
|
+
id: 'my-app.banner',
|
|
238
|
+
zone: 'page-content-before',
|
|
239
|
+
routes: [{ pattern: '/manage/:domain/users' }],
|
|
240
|
+
component: Banner,
|
|
241
|
+
});
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Dynamic table columns
|
|
245
|
+
|
|
246
|
+
Add columns to host DataGrids that opt in (zone names like `call-logs-columns`, `users-columns`, etc.):
|
|
247
|
+
|
|
248
|
+
```tsx
|
|
249
|
+
sdk.registerDynamicColumn({
|
|
250
|
+
id: 'my-app.priority',
|
|
251
|
+
zone: 'call-logs-columns',
|
|
252
|
+
routes: [{ pattern: '/manage/*/call-logs' }],
|
|
253
|
+
column: {
|
|
254
|
+
field: 'priority',
|
|
255
|
+
headerName: 'Priority',
|
|
256
|
+
width: 120,
|
|
257
|
+
sortable: true,
|
|
258
|
+
filterable: true,
|
|
259
|
+
renderCell: ({ row }) => <PriorityCell row={row} />,
|
|
260
|
+
valueGetter: (_v, row) => computePriority(row),
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Hook: `useDynamicColumn(eventBus, appId, config)`.
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## Using `horizonContext.ui`
|
|
270
|
+
|
|
271
|
+
Don't ship MUI yourself — render Horizon's themed components instead. They inherit the host's Aurora MUI theme, including automatic dark/light mode switching.
|
|
272
|
+
|
|
273
|
+
The recommended pattern uses `useHorizonContext()` (see Routes above). The old pattern of accepting `horizonContext` as props still works for extension components:
|
|
274
|
+
|
|
275
|
+
```tsx
|
|
276
|
+
// Recommended — page component using the hook (fully reactive theme)
|
|
277
|
+
import { useHorizonContext } from '@netsapiens/horizon-sdk';
|
|
278
|
+
|
|
279
|
+
export default function MyPage() {
|
|
280
|
+
const { ui, user } = useHorizonContext();
|
|
281
|
+
const { PageTemplate, DatagridTemplate } = ui.templates;
|
|
282
|
+
const { Button, Stack, Typography } = ui;
|
|
283
|
+
|
|
284
|
+
return (
|
|
285
|
+
<PageTemplate title="My Page" breadcrumbs={[{ label: 'Apps', url: '/apps' }]}>
|
|
286
|
+
<Stack spacing={2}>
|
|
287
|
+
<Typography variant="h5">Hello, {user.displayName}</Typography>
|
|
288
|
+
<Button variant="contained">Action</Button>
|
|
289
|
+
</Stack>
|
|
290
|
+
</PageTemplate>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
```tsx
|
|
296
|
+
// Legacy — still works for extension components (not full pages)
|
|
297
|
+
function MyButton({ context }: ExtensionComponentProps) {
|
|
298
|
+
const { Button } = context.ui ?? {};
|
|
299
|
+
return <Button variant="contained">Click</Button>;
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Available under `horizonContext.ui` (or via `useHorizonContext().ui`):
|
|
304
|
+
|
|
305
|
+
- `templates`: `PageTemplate`, `PageTemplateWithExtensions`, `FormTemplate`, `SideTrayTemplate`, `DatagridTemplate`
|
|
306
|
+
|
|
307
|
+
> **`DatagridTemplate` and the MUI X Pro license** — `DatagridTemplate` 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.
|
|
308
|
+
|
|
309
|
+
- Form: `Button`, `IconButton`, `TextField`, `Select`, `Checkbox`, `Radio`, `RadioGroup`, `Switch`, `ToggleButton`, `ToggleButtonGroup`, `FormLabel`
|
|
310
|
+
- Display: `Typography`, `Chip`, `Avatar`, `Divider`, `Tooltip`, `Icon`
|
|
311
|
+
- Layout: `Stack`, `Paper`
|
|
312
|
+
- Feedback: `Alert`
|
|
313
|
+
- `theme` (`'light' | 'dark'`, reactive via `useHorizonContext()`) and `styles` for derived tokens
|
|
314
|
+
|
|
315
|
+
#### IconButton — shorthand-only API
|
|
316
|
+
|
|
317
|
+
`IconButton` does **not** accept the standard MUI children pattern. It is a shorthand wrapper that takes the icon name as a string prop:
|
|
318
|
+
|
|
319
|
+
```tsx
|
|
320
|
+
// ✅ Correct — pass the Iconify name via the `icon` prop
|
|
321
|
+
<IconButton icon="mdi:account" aria-label="Account" />
|
|
322
|
+
<IconButton icon="material-symbols:bolt" iconSize={18} size="small" onClick={handleClick} />
|
|
323
|
+
|
|
324
|
+
// ❌ Wrong — children are dropped, the button renders empty at 0×0
|
|
325
|
+
<IconButton aria-label="Account">
|
|
326
|
+
<Icon icon="mdi:account" />
|
|
327
|
+
</IconButton>
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
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.
|
|
331
|
+
|
|
332
|
+
Other themed components (`Button`, `Stack`, `Paper`, etc.) follow the standard MUI children pattern.
|
|
333
|
+
|
|
334
|
+
---
|
|
335
|
+
|
|
336
|
+
## Dark mode
|
|
337
|
+
|
|
338
|
+
Dark mode is handled automatically — **you write no theme-switching code for MUI components**.
|
|
339
|
+
|
|
340
|
+
MUI components (`Button`, `Paper`, `Stack`, `DatagridTemplate`, etc.) inherit the host's Aurora 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()`:
|
|
341
|
+
|
|
342
|
+
```tsx
|
|
343
|
+
import { useTheme } from '@netsapiens/horizon-sdk';
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
`useTheme()` is a single hook that works identically in both contexts:
|
|
347
|
+
|
|
348
|
+
| Where you use it | How it works |
|
|
349
|
+
| ------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
|
350
|
+
| **Page component** (inside `HorizonContextProvider`) | Reads directly from provider React context — zero subscription overhead |
|
|
351
|
+
| **Extension component** (registered via `registerDynamicExtension`) | Pass `context.eventBus`; hook subscribes and unsubscribes automatically |
|
|
352
|
+
|
|
353
|
+
```tsx
|
|
354
|
+
// Page component
|
|
355
|
+
export default function MyPage() {
|
|
356
|
+
const { theme } = useTheme(); // no argument needed inside HorizonContextProvider
|
|
357
|
+
return <div style={{ background: theme === 'dark' ? '#1B2124' : '#fff' }}>...</div>;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Extension component
|
|
361
|
+
export default function MyButton({ context }: ExtensionComponentProps) {
|
|
362
|
+
const { theme } = useTheme(context.eventBus); // pass eventBus for extensions
|
|
363
|
+
const { Button } = context.ui ?? {};
|
|
364
|
+
return <Button>{theme === 'dark' ? '🌙' : '☀️'} Export</Button>;
|
|
365
|
+
}
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
> Do not subscribe to `theme:changed` on the event bus manually. `useTheme()` handles the subscription and cleanup internally.
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
## API client
|
|
373
|
+
|
|
374
|
+
```tsx
|
|
375
|
+
const { api, user } = horizonContext;
|
|
376
|
+
const devices = await api.get(`/domains/${user.domain}/users/${user.displayName}/devices`);
|
|
377
|
+
await api.post(`/domains/${user.domain}/users/${user.displayName}/contacts`, {
|
|
378
|
+
'name-first-name': 'John',
|
|
379
|
+
});
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
All calls run through Horizon's audited proxy — credentials never reach the remote app.
|
|
383
|
+
|
|
384
|
+
---
|
|
385
|
+
|
|
386
|
+
## Event bus
|
|
387
|
+
|
|
388
|
+
For cross-app messages and host events:
|
|
389
|
+
|
|
390
|
+
```tsx
|
|
391
|
+
useEffect(() => {
|
|
392
|
+
const handler = (data: unknown) => console.log('theme changed', data);
|
|
393
|
+
horizonContext.eventBus.on('theme:changed', handler);
|
|
394
|
+
return () => horizonContext.eventBus.off('theme:changed', handler);
|
|
395
|
+
}, []);
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Common host-emitted events: `theme:changed`, `call-event`, `routes:updated`. Apps can emit any custom event under their own namespace (`my-app:something`).
|
|
399
|
+
|
|
400
|
+
> **You do not need to subscribe to `theme:changed` anywhere.** Use `useTheme()` from the SDK — it handles the subscription internally for both page components and extension components. See the Dark mode section.
|
|
401
|
+
|
|
402
|
+
---
|
|
403
|
+
|
|
404
|
+
## Hosting requirements
|
|
405
|
+
|
|
406
|
+
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`.
|
|
407
|
+
|
|
408
|
+
**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.
|
|
409
|
+
|
|
410
|
+
**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`).
|
|
411
|
+
|
|
412
|
+
**CORS** — The Horizon host makes a cross-origin request to fetch your `remoteEntry.js`. Your server must respond with:
|
|
413
|
+
|
|
414
|
+
```
|
|
415
|
+
Access-Control-Allow-Origin: https://your-horizon-instance.com
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
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.
|
|
419
|
+
|
|
420
|
+
**Domain whitelist** — In addition to CORS, your CDN domain must be approved by the Horizon administrator. See [Registering with Horizon](#registering-with-horizon) below for how to request approval.
|
|
421
|
+
|
|
422
|
+
---
|
|
423
|
+
|
|
424
|
+
## Build configuration
|
|
425
|
+
|
|
426
|
+
Use webpack Module Federation. Minimal `webpack.config.js`:
|
|
427
|
+
|
|
428
|
+
```js
|
|
429
|
+
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
|
|
430
|
+
|
|
431
|
+
module.exports = (_env, argv) => ({
|
|
432
|
+
entry: './src/App.tsx',
|
|
433
|
+
output: { publicPath: 'auto', filename: 'remoteEntry.js', clean: true },
|
|
434
|
+
resolve: { extensions: ['.tsx', '.ts', '.js'] },
|
|
435
|
+
module: {
|
|
436
|
+
rules: [{ test: /\.(tsx?|jsx?)$/, exclude: /node_modules/, use: 'babel-loader' }],
|
|
437
|
+
},
|
|
438
|
+
plugins: [
|
|
439
|
+
new ModuleFederationPlugin({
|
|
440
|
+
name: 'myApp', // your Extension App ID — must match exactly what you enter during registration
|
|
441
|
+
filename: 'remoteEntry.js',
|
|
442
|
+
exposes: { './App': './src/App' },
|
|
443
|
+
shared: {
|
|
444
|
+
react: { singleton: true, requiredVersion: '^19.0.0' },
|
|
445
|
+
'react-dom': { singleton: true, requiredVersion: '^19.0.0' },
|
|
446
|
+
loglevel: { singleton: true, requiredVersion: '^1.9.0' },
|
|
447
|
+
'@netsapiens/horizon-sdk': { singleton: true, requiredVersion: '^1.0.0' },
|
|
448
|
+
|
|
449
|
+
// Do NOT add @mui/material, @emotion/*, or @mui/x-data-grid-pro here.
|
|
450
|
+
// The host's federation loader does not register MUI as a shared module,
|
|
451
|
+
// so declaring it as a singleton causes an "Unsatisfied version" crash.
|
|
452
|
+
//
|
|
453
|
+
// Instead, consume all MUI components via horizonContext.ui — this is
|
|
454
|
+
// what gives your components the Aurora theme and dark mode automatically.
|
|
455
|
+
},
|
|
456
|
+
}),
|
|
457
|
+
],
|
|
458
|
+
devServer: { port: 5005, headers: { 'Access-Control-Allow-Origin': '*' } },
|
|
459
|
+
});
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
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.
|
|
463
|
+
|
|
464
|
+
---
|
|
465
|
+
|
|
466
|
+
## Registering with Horizon
|
|
467
|
+
|
|
468
|
+
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:
|
|
469
|
+
|
|
470
|
+
```bash
|
|
471
|
+
curl -X POST "https://your-horizon-instance.com/ns-api/v2/ui-extensions" \
|
|
472
|
+
-H "Authorization: Bearer YOUR_TOKEN" \
|
|
473
|
+
-H "Content-Type: application/json" \
|
|
474
|
+
-d '{
|
|
475
|
+
"name": "My App",
|
|
476
|
+
"description": "Short description shown in the app list",
|
|
477
|
+
"version": "1.0.0",
|
|
478
|
+
"remote_entry_url": "https://cdn.example.com/my-app/remoteEntry.js",
|
|
479
|
+
"module_scope": "myApp",
|
|
480
|
+
"author": "Acme Corp",
|
|
481
|
+
"enabled": true
|
|
482
|
+
}'
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
### Registration fields
|
|
486
|
+
|
|
487
|
+
| Field | Required | Notes |
|
|
488
|
+
| -------------------- | -------- | --------------------------------------------------------------------------------------------- |
|
|
489
|
+
| **Name** | ✓ | Display name shown in the navigation menu and admin UI |
|
|
490
|
+
| **Description** | | Brief summary shown as a subtitle in the app list |
|
|
491
|
+
| **Version** | ✓ | Semantic version (e.g. `1.0.0`). Used for display and auditing only — does not affect caching |
|
|
492
|
+
| **Remote Entry URL** | ✓ | Full HTTPS URL to your `remoteEntry.js`. See Hosting requirements above |
|
|
493
|
+
| **Extension App ID** | ✓ | See below |
|
|
494
|
+
| **Author** | | Individual or company name. Shown in app details |
|
|
495
|
+
| **Enabled** | | Defaults to `true`. Set to `false` to register without immediately activating |
|
|
496
|
+
|
|
497
|
+
### Extension App ID
|
|
498
|
+
|
|
499
|
+
The **Extension App ID** (stored as `module_scope`) is the single most important field to get right. It must **exactly match** the `name` value in your webpack `ModuleFederationPlugin` config:
|
|
500
|
+
|
|
501
|
+
```js
|
|
502
|
+
// webpack.config.js
|
|
503
|
+
new ModuleFederationPlugin({
|
|
504
|
+
name: 'myUcaasApp', // ← this is your Extension App ID
|
|
505
|
+
...
|
|
506
|
+
})
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
```json
|
|
510
|
+
// Registration
|
|
511
|
+
{ "module_scope": "myUcaasApp" }
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
**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.
|
|
515
|
+
|
|
516
|
+
**Rules:**
|
|
517
|
+
|
|
518
|
+
- Must be unique across every app registered on the platform — duplicate names cause both apps to fail
|
|
519
|
+
- Use camelCase (e.g. `myUcaasApp`, not `my-ucaas-app` or `MyUcaasApp`)
|
|
520
|
+
- No spaces or special characters
|
|
521
|
+
|
|
522
|
+
### Domain whitelist
|
|
523
|
+
|
|
524
|
+
Your CDN domain must be approved by the Horizon administrator before the platform will load your app. Contact your admin and provide:
|
|
525
|
+
|
|
526
|
+
- Your CDN domain or pattern (e.g. `cdn.example.com` or `*.example.com`)
|
|
527
|
+
- Whether you need localhost approved for local development
|
|
528
|
+
|
|
529
|
+
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.
|
|
530
|
+
|
|
531
|
+
**For local development:** Ask your admin to temporarily add `localhost:5005` (or whatever port your dev server uses) to the approved list.
|
|
532
|
+
|
|
533
|
+
### Verifying your app loaded
|
|
534
|
+
|
|
535
|
+
After registering and refreshing the browser:
|
|
536
|
+
|
|
537
|
+
1. **Open DevTools → Console** and look for log lines starting with `[Demo App]` or your app name — your `App.tsx` runs on page load and any `console.log` calls appear here
|
|
538
|
+
|
|
539
|
+
2. **Check the network tab** — filter by your domain and look for `remoteEntry.js`. A `200` response confirms the bundle was fetched. A `CORS error` or `net::ERR_*` means the domain isn't reachable or CORS headers are missing
|
|
540
|
+
|
|
541
|
+
3. **Look for your registered routes** — if you called `sdk.registerRoute`, your page should appear in the navigation sidebar immediately after the app loads
|
|
542
|
+
|
|
543
|
+
4. **Look for your extensions** — visit the route pattern your extension targets (e.g. `/manage/call-logs`) and confirm the injected component appears
|
|
544
|
+
|
|
545
|
+
5. **Check the Registered Apps page** (Platform → UI SDK Management → Registered Apps) — the **Extension Zones** column will show which zones your app has active extensions registered in once it has loaded
|
|
546
|
+
|
|
547
|
+
If nothing appears after a full page refresh:
|
|
548
|
+
|
|
549
|
+
- Confirm the **Enabled** toggle is on in the admin UI
|
|
550
|
+
- Confirm your CDN domain is approved (see above)
|
|
551
|
+
- Check for JavaScript errors in the console — a crash in your `App.tsx` before `sdk.registerRoute` is called means nothing gets registered
|
|
552
|
+
|
|
553
|
+
---
|
|
554
|
+
|
|
555
|
+
## After registration
|
|
556
|
+
|
|
557
|
+
**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.
|
|
558
|
+
|
|
559
|
+
**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.
|
|
560
|
+
|
|
561
|
+
**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 will see the change on their next page load.
|
|
562
|
+
|
|
563
|
+
---
|
|
564
|
+
|
|
565
|
+
## Managing your app
|
|
566
|
+
|
|
567
|
+
### Updating a deployed app
|
|
568
|
+
|
|
569
|
+
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.
|
|
570
|
+
|
|
571
|
+
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.
|
|
572
|
+
|
|
573
|
+
Active sessions continue using the version that loaded when they started. Users see the new version on their next page refresh.
|
|
574
|
+
|
|
575
|
+
### Disable vs. Delete
|
|
576
|
+
|
|
577
|
+
| Action | Effect | Reversible? |
|
|
578
|
+
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------- |
|
|
579
|
+
| **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 |
|
|
580
|
+
| **Delete** | Permanently removes the registration. Cannot be undone. | ✗ |
|
|
581
|
+
|
|
582
|
+
Use **Disable** when you need to take an app offline temporarily (maintenance, incident response). Use **Delete** only when decommissioning an app permanently.
|
|
583
|
+
|
|
584
|
+
---
|
|
585
|
+
|
|
586
|
+
## Cleanup & lifecycle
|
|
587
|
+
|
|
588
|
+
`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.
|
|
589
|
+
|
|
590
|
+
---
|
|
591
|
+
|
|
592
|
+
## Errors
|
|
593
|
+
|
|
594
|
+
Throw and catch typed errors with codes:
|
|
595
|
+
|
|
596
|
+
```tsx
|
|
597
|
+
import { HorizonSDKError, apiError } from '@netsapiens/horizon-sdk';
|
|
598
|
+
|
|
599
|
+
try {
|
|
600
|
+
await api.get('/...');
|
|
601
|
+
} catch (err) {
|
|
602
|
+
if (HorizonSDKError.isHorizonSDKError(err)) {
|
|
603
|
+
console.error(err.code, err.getUserMessage());
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
Codes: `PERMISSION_DENIED`, `RATE_LIMIT_EXCEEDED`, `API_ERROR`, `NETWORK_ERROR`, `MODULE_LOAD_FAILED`, `INITIALIZATION_FAILED`, etc.
|
|
609
|
+
|
|
610
|
+
---
|
|
611
|
+
|
|
612
|
+
## Scaffolding a new app with Claude Code
|
|
613
|
+
|
|
614
|
+
Run the slash command:
|
|
615
|
+
|
|
616
|
+
```
|
|
617
|
+
/create-horizon-app
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
(see `.claude/skills/create-horizon-app.md` for the full prompt.)
|
|
621
|
+
|
|
622
|
+
Or paste this into a Claude Code session:
|
|
623
|
+
|
|
624
|
+
> Scaffold a new remote app for NetSapiens Horizon called `<app-name>` with Extension App ID `<extensionAppId>` on port `<port>`. It should:
|
|
625
|
+
>
|
|
626
|
+
> - Use webpack Module Federation, expose `./App`, and share react/react-dom/loglevel/@netsapiens/horizon-sdk as singletons
|
|
627
|
+
> - Have an `App.tsx` that uses `useRemoteApp(horizonContext, '<app-id>')` and registers one route at `/home/<app-id>` plus one dynamic extension on `page-header-actions` for `/manage/*/users`
|
|
628
|
+
> - Use `HorizonContextProvider` + `useHorizonContext()` for all page components so dark mode works automatically
|
|
629
|
+
> - Do NOT share `@mui/material`, `@mui/x-data-grid-pro`, `@emotion/*` in webpack — these are not provided by the host's federation loader
|
|
630
|
+
> - Render all UI through `horizonContext.ui` to get themed components
|
|
631
|
+
> - Include a README with run + register instructions
|
|
632
|
+
|
|
633
|
+
For one-off tasks inside an existing remote app, prompts like these work well:
|
|
634
|
+
|
|
635
|
+
> Add a dynamic table column to the call-logs grid that shows call priority. Use `sdk.registerDynamicColumn` with zone `call-logs-columns` and route pattern `/manage/*/call-logs`. The column should sort and filter by computed priority (`high`/`medium`/`low`).
|
|
636
|
+
|
|
637
|
+
> Add an extension to `inbound-call-content` zone that fetches caller info from our CRM API and shows name + last contact date. Subscribe to `call-event` on the event bus and enrich the call data through `horizonContext.api`.
|
|
638
|
+
|
|
639
|
+
---
|
|
640
|
+
|
|
641
|
+
## License
|
|
642
|
+
|
|
643
|
+
MIT
|