@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 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