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

@netup/unified-admin-contract

v11.13.0

Published

Host ↔ module contract for the NetUP unified admin UI: TypeScript types, contract version, and pure compatibility checkers

Readme

@netup/unified-admin-contract

The host ↔ module contract of the NetUP unified admin UI: TypeScript types, the contract version constant, and pure compatibility checkers.

The NetUP unified admin is a shell (host) that mounts admin UIs of independently delivered modules — IPTV middleware, digital signage, VoD and custom third-party modules — under one menu, one URL and one session. This package pins down the boundary between the host and a module so that a module can be developed, versioned and shipped independently of the host and of other modules.

  • Zero dependencies. Types and pure functions only.
  • Plain-JS boundary. The contract passes callbacks, DOM nodes and observables — never framework objects. Host and modules may use different React/MUI/router versions, or no React at all. Methods are always called on their owner object, so either side may implement them with classes — see Methods are called on their owner.
  • Degradation first. Additive contract extensions are optional fields with runtime feature-detection; version bumps happen only on breaking changes. See Compatibility policy.

Installation

npm install @netup/unified-admin-contract

Architecture

The host owns the master layout: the drawer menu, the app bar, snackbars, the error log and top-level routing. Every module is mounted as an isolated root: the host provides a DOM node, the module renders its whole UI tree into it with its own framework copy, stores and contexts. There are no shared framework singletons — everything the module needs from the shell comes through a single HostApi object, and everything the host needs to know about the module comes back through ModuleState reports and the EmbeddedHandle returned by mount.

┌────────────────────────── host shell ──────────────────────────┐
│ drawer · app bar · snackbars · error log · top-level routing   │
│                                                                │
│   ┌── module root A ──┐   ┌── module root B ──┐                │
│   │ own React/MUI     │   │ any framework     │                │
│   │ own stores/i18n   │   │                   │                │
│   └───────▲───────────┘   └───────▲───────────┘                │
│           │ HostApi / EmbeddedHandle │                         │
└───────────┴───────────────────────┴────────────────────────────┘

Module isolation is a deliberate trade-off: bundles are larger (each module carries its own framework), but modules release independently, legacy modules can be frozen, and third-party modules do not have to track the host's internal library versions.

Module anatomy

A module is a self-contained web application plus a small contract surface:

  • Remote entry. The module's UI ships as a Module Federation bundle whose exposed ./module file default-exports a ModuleManifest.
  • Isolated root. The module renders into a host-provided DOM node and renders one active page at a time — the host owns the menu and the URL and tells the module which page to show via EmbeddedHandle.setPage.
  • Self-describing delivery. The module's container image declares unified-admin support in a docker label, so the host discovers modules without loading any code. See Building a module.

Building a module

End-to-end walkthrough for creating a module from scratch — frontend, backend, and the container that delivers both.

1. Write the manifest

Create a file that default-exports a ModuleManifest. mount receives the host container node and the HostApi instance and returns an EmbeddedHandle. See Examples for a complete minimal module.

// src/manifest.ts
import { CONTRACT_VERSION, ModuleManifest } from '@netup/unified-admin-contract';

const manifest: ModuleManifest = {
  id: 'acme',
  contractVersion: CONTRACT_VERSION,
  title: 'ACME',
  mount(container, hostApi) {
    /* mount your UI into `container`, wire it to `hostApi` */
  },
};

export default manifest;

2. Build the remote entry (Vite + Module Federation 2)

The host loads modules with the Module Federation 2 manifest protocol: it fetches mf-manifest.json, then the remoteEntry.js it references, then takes the default export of the exposed ./module.

// vite.config.ts
import { federation } from '@module-federation/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    federation({
      name: 'acme',
      filename: 'remoteEntry.js',
      manifest: true,
      exposes: { './module': './src/manifest.ts' },
      shared: {},
    }),
  ],
  build: { target: 'esnext' },
});

Keep shared empty (or minimal and deliberate): the module is expected to be self-contained, nothing is forced into a host-shared singleton.

3. Backend, static files and the nginx route

The module's container runs its own backend process listening on a unix socket under /run/netup/ (module containers use host networking and share /run with the platform). The backend serves the module's API and the frontend static files — including mf-manifest.json and remoteEntry.js — under the module's web root.

All HTTP enters through the platform's nginx: it validates the admin session and proxies /<module-base>/… requests to the module's socket. Routes for the built-in NetUP modules ship with the platform; a custom module adds its route to the persistent nginx include /netup/sysconfig/nginx/default_server.conf (included inside the server block; the file survives reboots and platform updates). A minimal route:

location /acme/ {
    add_header Cache-Control $mf_cache_control always;
    auth_request /auth;
    error_page 401 /login_wrapper.html;
    proxy_pass http://unix:/run/netup/acme.sock:/;
}
  • auth_request /auth plugs the module into the platform's single sign-on: nginx validates the admin session and forwards the verified operator identity to your backend (see below).
  • $mf_cache_control (a map defined by the platform config) marks remoteEntry.js/mf-manifest.json as no-cache, so the shell never sticks to a stale bundle after a module update.
  • Add dedicated locations for WebSocket endpoints (Upgrade/Connection headers) and large uploads (client_max_body_size) as needed.

Identity headers. Behind auth_request every proxied request carries the authenticated operator's identity:

| Header | Value | | -------------- | --------------------------------------------------------------------------------------------------- | | X-Auth-Login | Platform login of the operator (for an API-token request — the token owner's login) | | X-Auth-Roles | The operator's roles, comma-separated, expanded and deduplicated; absent when the user has no roles | | X-Auth-Token | API-token id when the request was authenticated with a token (for audit trails) |

Each token (the login, every role name) is percent-encoded individually (RFC 3986), so the comma separator is unambiguous: split on ,, then percent-decode each token. nginx always overwrites these headers — a client cannot spoof them — so the backend can base its own authorization on them. NetUP modules build a per-role permission matrix on top of X-Auth-Roles (enforced server-side by the module) and expose its editor to the shell via hasPermissions/mountPermissions — see Permissions editor fragment.

The host passes the resolved web root to the module at mount time as HostApi.baseUri — build your API and asset URLs from it rather than from document.baseURI (which belongs to the host page in the unified shell).

4. Package the container

A module is delivered as a docker image with tv.netup.service.* labels; the platform inventories containers by these labels.

FROM debian:trixie-slim
COPY backend /srv/acme/
COPY frontend/dist/ /srv/acme/www/
CMD ["/srv/acme/acme-backend"]

LABEL tv.netup.service.name=acme \
      tv.netup.service.unified-admin='{"contract":11,"id":"acme","remoteEntry":"remoteEntry.js","mfManifest":"mf-manifest.json"}' \
      tv.netup.service.version=1.0 \
      tv.netup.service.build=42

| Label | Purpose | | ------------------------------------ | ------------------------------------------------------------------------------------------- | | tv.netup.service.name | Service name; the key the host maps to the module's web root | | tv.netup.service.unified-admin | JSON UnifiedAdminDescriptor: declares unified-admin support before any code is loaded | | tv.netup.service.version, .build | Code identity; the host watches them to detect a newly deployed module version (hot update) | | tv.netup.service.constraints | Optional JSON: dependency/conflict semver ranges on other packages (see below) | | tv.netup.service.provides | Optional JSON: services inside the container, drives platform log rotation (see below) | | tv.netup.service.mounts | Optional host directories the container needs |

Descriptor fields. The shell loads a module from mfManifest: the Module Federation manifest names the entry file itself. remoteEntry is declarative — keep it truthful, but a host is not required to read it, and the NETUP shell does not. contract and id are the two fields every host acts on.

Dependencies and conflicts. tv.netup.service.constraints is a JSON object with optional dependencies and conflicts maps: package name → semver range, checked against that package's installed <version>.<build> (see "Version format" below). Package names are service names (system, netup_nginx, mw, …) plus the platform pseudo-packages firmware and linux_kernel. A dependency is broken when the package is missing or its version is outside the range; a conflict fires when the package is installed and its version falls inside the range. The platform re-evaluates the whole set on every install/update/remove and warns the operator about violations the action would introduce, asking for confirmation.

{
  "dependencies": { "firmware": ">=trunk.232", "netup_nginx": ">=master.4275", "system": ">=master.4275" },
  "conflicts": { "acme-legacy": "*" }
}

Always declare a dependency on a system version that implements the contract major your module is built with — then installing the module onto an older platform warns the operator right at install time, instead of the module silently being skipped by the shell at runtime (the runtime exact-major check still applies; the constraint just surfaces the problem earlier). Contract major 11 ships with system master.4271 and newer; the identity headers from step 3 are forwarded since master.4275 — hence the "system": ">=master.4275" pin in the example above, covering both.

Log rotation. tv.netup.service.provides is a JSON array declaring the services running inside the container; the platform's log rotator uses it. For each entry it renames the files listed in logs (relative to the shared /netup/log directory) and then executes rotateCmd inside the container — the command must make the process reopen its log files:

[{ "name": "acme", "rotateCmd": "pkill -USR2 -x acme-backend", "logs": ["acme.log"] }]

Version format. The platform treats <version>.<build> as the module's full version and compares it with semver logic (dependency and conflict ranges in tv.netup.service.constraints, "newer version deployed" detection). Keep tv.netup.service.version numeric (major.minor, e.g. 1.0) and tv.netup.service.build a plain number. The string values master and trunk are reserved for NetUP's own CI builds and are handled specially (they always compare as newer than any numeric version) — do not use them for a custom module.

The contract field of the label must equal the CONTRACT_VERSION your bundle was built with — derive it from the installed package at build time instead of hard-coding, so the label can never disagree with the runtime manifest:

CONTRACT=$(node -p "require('@netup/unified-admin-contract/package.json').version.split('.')[0]")

Ship the image as an .nsp.xz archive:

docker save acme:1.0.42 | xz > acme-1.0.42.nsp.xz

5. Install and discovery

Install the archive on the NetUP server (the "Modules & services" page of the System admin, or create-service on the command line). Discovery is then automatic:

  1. The host backend reads docker labels and exposes them to the shell.
  2. The shell filters running services carrying the unified-admin label and checks the label's contract against its own CONTRACT_VERSION (exact-major, see Compatibility policy).
  3. For compatible candidates it loads mf-manifest.json → remoteEntry.js → the ModuleManifest, and re-checks manifest.contractVersion.
  4. Incompatible or broken modules are skipped with a visible indication in the shell — they never break other modules.

Base URI registration. The nginx route is under your control (see step 3), but the shell's mapping "service name → web root" is currently a fixed registry inside the platform. A third-party module needs its service name and base path registered on the platform side — contact NetUP when planning a custom module.

6. Verify

With the module installed and running: the unified admin card appears on the System home screen, the module's pages appear in the unified menu, and the "Application information" dialog lists the module with its versions. If the module reports missing host features or a pair mismatch, the shell shows a degraded-state warning there.

Module manifest

interface ModuleManifest {
  id: string;
  contractVersion: number;
  title: string;
  order?: number;
  remoteEntry?: string;
  mount(container: Element, hostApi: HostApi): EmbeddedHandle;
}
  • id — unique module id; also the URL segment of the module inside the shell.
  • contractVersion — the contract major the bundle was built with; export CONTRACT_VERSION from this package, never a literal.
  • title — human-readable module name; the host labels the module with it until the module reports a localized one, see Module display name.
  • order — drawer ordering priority: the host sorts ascending, ties broken by id, absent value sorts last.
  • mount — the single entry point of the module's embedded UI.

The manifest is deliberately thin. Everything dynamic — menu pages, feature flags, settings presence — travels through the ModuleState report after mount, because the manifest is read before mounting and has no degradation channel.

Mount lifecycle

mount(container, hostApi) must:

  1. Render the module UI into container (an empty block element owned by the host; the host never touches its children).
  2. Subscribe to the hostApi observables it needs (theme, locale, optionally license).
  3. Report its state via hostApi.setModuleState (and keep re-reporting when locale, license or feature flags change visibility or titles).
  4. Return an EmbeddedHandle.
interface EmbeddedHandle {
  store: unknown; // deprecated: no host reads it, removal scheduled for the next major
  setPage(page: string): void;
  mountSettings(node: HTMLElement): Unsubscribe;
  mountVersionInfo(node: HTMLElement): Unsubscribe;
  mountPermissions?(
    node: HTMLElement,
    role: string,
    context?: Observable<PermissionsEditorContext | undefined>
  ): Unsubscribe;
  mountArea?(id: string, node: HTMLElement, context: Observable<AreaContext>): Unsubscribe;
  unmount(): void;
}
  • setPage(page) — the host resolved the shell URL to a module page path; the module renders that page. The module never mounts its own top-level router UI (drawer, app bar, footer) — the host owns them.
  • mountSettings(node) — render the module's settings tab content into a host-provided node (the host builds a per-module tab in its settings dialog when ModuleState.hasSettings/hasFeatureFlags is reported). Returns a cleanup function.
  • mountVersionInfo(node) — render the module's version rows for the host's "Application information" view. The node is a table body of the host's section table, so render table rows into it — see Settings and version info.
  • mountPermissions(node, role, context?) — optional; see Permissions editor fragment.
  • mountArea(id, node, context) — optional, donor side of Area contributions.
  • unmount() — tear everything down: unsubscribe, stop timers/sockets, unmount the root. The host calls it when leaving the shell and before re-mounting a newly deployed module version (hot update).

All mount* methods are portals: the content they render stays inside the module's own component tree (so stores, contexts and translations keep working) while being displayed in a host-owned DOM node.

HostApi reference

interface HostApi {
  contractVersion: number;
  baseUri: string;
  capabilities?: readonly HostCapability[];
  license?: Observable<LicenseDocument | undefined>;
  areas?: AreaBroker;
  search?: Observable<string>;
  visible?: Observable<boolean>;
  reportError(error: ErrorData): void;
  enqueueSnackbar(snackbar: SnackbarRequest): void;
  setModuleState?(state: ModuleState): void;
  routing: RoutingSource;
  theme: Observable<ThemeMode>;
  locale: Observable<string>;
  shell: {/* see Shell channel */};
}

Each module root receives its own HostApi instance. Optional fields are additive contract extensions — always feature-detect (see Compatibility policy).

Observable<T> is the minimal push primitive of the contract:

interface Observable<T> {
  get(): T;
  subscribe(listener: (value: T) => void): Unsubscribe;
}

Methods are called on their owner

Every method of this contract is invoked on the object that carries it — hostApi.theme.subscribe(listener), hostApi.shell.setTitle(title), handle.setPage(page) — never through a reference detached from that object.

The contract therefore does not require closure-based objects: a host is free to implement Observable, RoutingSource, shell or any other member with classes, getters or prototype methods that need this. The same holds in the other direction — a module may return an EmbeddedHandle whose methods live on a prototype.

React makes detaching easy to do by accident: passing subscribe straight into useSyncExternalStore hands over the method without its owner. Wrap it instead (listener => source.subscribe(listener)) and keep the wrapper stable — memoize it per channel, because useSyncExternalStore resubscribes whenever the subscribe reference changes. Feature-detecting an optional method is a check of the field (if (hostApi.shell.setSearchShown)), while the call still goes through the owner.

Routing

The shell URL is one URL for the whole unified admin; the module's internal navigation is namespaced under /<module-id>/… by the host. The host hands the module a low-level RoutingSource:

interface RoutingSource {
  getSnapshot(): { pathname: string; search: string };
  subscribe(listener: () => void): Unsubscribe;
  navigate(to: To, options?: { replace?: boolean; state?: unknown }): void;
  createHref(to: To): string;
  getParams?(): Record<string, string | undefined>;
  back?(): void;
}

Paths are module-relative: the host strips its own prefix before calling the module and prepends it inside navigate/createHref. back is optional (additive): a module must fall back to its own navigation when it is absent.

getParams is deprecated and scheduled for removal in the next major: no host implements it. Resolve path parameters by matching getSnapshot().pathname against your own route patterns.

RouterAdapter is the hook-shaped view of the same source for React-based modules; To, NavigateOptions and SetURLSearchParams are structural copies of the react-router v7 types, assignment-compatible in both directions, so react-router values pass straight through.

Theme and locale

hostApi.theme ('auto' | 'light' | 'dark') and hostApi.locale (BCP-47 language tag) are host-owned observables. The module applies them to its own theming and i18n instances — there is no shared theme or i18n singleton, so a module can localize with any library and any set of languages.

Errors and snackbars

The module forwards errors and notifications to the host so the user gets one error log and one snackbar stack:

  • reportError(error: ErrorData) — append an entry to the host error log. ErrorData is a plain record: time (epoch ms), message, optional params, an id unique within the module's log, and the optional request url the failure came from.
  • enqueueSnackbar(snackbar: SnackbarRequest) — show a snackbar; type, title, persist, timeout and one optional action button are supported.

All strings are pre-localized by the module.

SnackbarRequest.timeout is how long the snackbar stays, in milliseconds. Omit it and the host applies its own default; persist: true keeps the snackbar until the user closes it, and then timeout is ignored. A module needs the field for notifications worth showing but not worth reading — "upload started" next to a progress indicator that already says so — where the host default outstays its welcome and covers the app bar. Passing it is unconditional, like ErrorData.url: a host that ignores the field applies its default, which is what the module got before the field existed.

ErrorData.url lets the host tell which service the request went to, not just which module reported it. A module may call another module's API (its own API is not the only address behind the shell), so a stopped service produces failures reported by a module that is perfectly healthy. A host that reads url can attribute those failures to the stopped service and count them instead of filling its error log with one entry per retry; a host that ignores the field behaves exactly as before. Reporting it is unconditional — no capability to feature-detect, since nothing in the module depends on the host reading it.

Shell channel

hostApi.shell carries the module's per-page shell state:

shell: {
  setDrawerHidden(hidden: boolean): void;
  setFooterHidden(hidden: boolean): void;
  mountAppBarIcons(node: HTMLElement | null): void;
  setAppBarIcons?(nodes: ShellAppBarNode[]): void; // see App-bar icon scale
  mountCustomAppBar(node: HTMLElement | null): void;
  setTitle(title: ShellTitle): void;
  setPreventUnload(reasons: ShellPreventReason[]): void;
  setUploadState(items, onAction): void; // see Upload state channel
  setSearchShown?(shown: boolean): void; // see Search channel
}
  • setTitle — a plain string or an array of ShellBreadcrumbs; crumb hrefs must be pre-resolved with routing.createHref so they are clickable host hrefs. The last crumb omits href.
  • setPreventUnload — the module's unsaved-changes guard: while the list is non-empty the host blocks leaving the shell (and beforeunload) and shows the reasons. In-module navigation is not blocked by the host — that stays the module's business.
  • mountAppBarIcons / mountCustomAppBar — portals for the module's app-bar icons or a full app-bar replacement, rendered by the module root into host-owned nodes. Pass null to detach a previously mounted node. A full-screen page is the composition: hidden drawer + custom (or empty) app bar.
  • setAppBarIcons — the same icons, but as separately placed nodes, so the host can interleave them with its own; see App-bar icon scale.
  • setDrawerHidden — hide the shell drawer for the active page.
  • setFooterHidden — deprecated, scheduled for removal in the next major. A host only hides what it draws, and no host draws a footer, so the call has no effect.
  • setSearchShown — ask for the host app-bar search field, see Search channel.

App-bar icon scale

Every icon of the bar — the host's own, the standard ones the bar draws itself (uploads, error log, settings, application menu) and those of the embedded modules — sits on one ascending scale, so a module can place its icon next to a specific neighbour instead of landing wherever its group happens to be:

shell.setAppBarIcons?.([{ id: 'taskQueue', node, order: 590 }]); // module → host
shell.setAppBarIcons?.([]); // detach

An entry is a node with a place on the scale, not necessarily a single icon: a module renders one node per icon when it wants them placed separately, or one node for the whole group when it does not care. An icon without order gets APP_BAR_ICON_ORDER.default.

Each call replaces the whole set. id is the module's own handle on a node — unique within the module and stable across calls: the host keeps a node mounted while its id stays in the set, and drops it once the id is gone.

Ties keep declaration order, so a group that must stay together is expressed by one shared number. Across modules — where the shared default makes ties the norm — the host breaks them the way it breaks menu ones, by module order then id, so the bar does not reshuffle between loads.

The scale is shared with the modules of the NetUP platform, and a value only means something relative to what is already there. APP_BAR_ICON_ORDER names the fixed points; the table is the reference — pick a free number between the neighbours you want, in steps that leave room for later insertions.

| order | Icon | Owner | | ----- | ------------------------------------------------- | ---------- | | 100 | status — connection status (link lost, reboot) | host | | 110 | module health | host | | 500 | default — icons declaring no order of their own | any module | | 590 | transcoding queue | ds | | 600 | uploads — upload indicator | app bar | | 650 | user menu | host | | 660 | support proxy | host | | 670 | power menu | host | | 680 | mobile menu | host | | 1000 | errors — error log | app bar | | 1100 | settings | app bar | | 1200 | menu — application menu | app bar |

The four owned by the app bar are the standard icons an application composes its bar from. A host that draws its own user menu (the NetUP shell does) leaves the application menu undrawn — its number and everything above it stay free.

Degradation. setAppBarIcons is optional. Against a host without it the module falls back to mountAppBarIcons and its icons stay one group in whatever place the host gives that group — the icons are all there, only their order differs, so the module reports shell.setAppBarIcons in ModuleState.cosmeticHostGaps rather than in missingHostFeatures, and only when it actually declared an order the fallback cannot honour.

Search channel

The app-bar search field belongs to the host: it is a part of the bar the host draws, and only the host knows which module is on screen. A module page that searches its list asks for the field and reads the typed text back:

shell.setSearchShown?.(true);              // module → host, per page
hostApi.search?.subscribe(text => ...);    // host → module, the typed text
  • The host owns the text. HostApi.search is read-only for the module: the field, its clear button and its lifetime are the host's. A module that clears its own search state does not clear the host field.
  • Only the active module is heard. A hidden module root keeps its pages mounted, so it keeps asking for the field; the host applies the request of the module the operator is currently looking at and ignores the rest.
  • The host resets the text when the active module or its active page changes — every page starts with an empty search, as it does standalone.
  • Degradation. Both members are optional. Against a host without them the module reports shell.setSearchShown / search in ModuleState.missingHostFeatures (functional: the page loses its search), and a host that has them shows no field until some module asks for one.

Visibility channel

Only one module root is on screen at a time: the host keeps every installed module mounted and switches which one is displayed. hostApi.visible tells a root whether it is the one the operator is looking at, so a hidden root can stop working in the background:

hostApi.visible?.subscribe(visible => ...);   // host → module

What a hidden root is expected to suspend is its page: unmount the page subtree so its data subscriptions and polling timers stop. What it must keep alive is everything the host displays outside the page — app-bar icons, the setModuleState report that builds the menu, area contributions mounted into other modules' pages, and the settings tab. The host reports the fact and leaves the policy to the module: only the module knows whether its page holds unsaved work or a running upload.

  • Suspend after a grace period, not immediately — switching to a neighbour module and back should not rebuild the page.
  • Never suspend a page holding unsaved changes or an active upload.
  • The URL survives. The host keeps addressing the same path, so a resumed page reopens on its tab, folder or detail view.

Degradation: the field is optional, and against a host without it a module never suspends — exactly the behaviour it had before the channel existed. Nothing goes into missingHostFeatures: no functionality is lost, only the saving (see Compatibility policy).

Upload state channel

The upload engine lives in the module (it knows its endpoints and processing); the indicator is host-owned and aggregates all modules. The module pushes its current upload list:

shell.setUploadState(items: ShellUploadItem[], onAction: (id: string, action: ShellUploadAction) => void)

ShellUploadItem carries pre-localized displayName/statusText, a status (UploadItemStatus), a progress fraction from 0 to 1 (not a percentage — the host scales it for display), an optional error and an optional doneAction (SVG icon markup + tooltip). The host renders the aggregate indicator and calls onAction(id, 'abort' | 'retry' | 'remove' | 'done') back into the module. Active uploads of any module also feed the host's leave-guard.

UploadItemStatus values and what a host is expected to offer for each:

| Status | Meaning | Actions | | --------------------- | ---------------------------------------------------------- | ------------- | | pending | queued, transfer not started | abort | | uploading | transferring, progress meaningful | abort | | processing | transferred, server-side processing, progress meaningful | abort | | processing-orphaned | processing was lost track of (server restart, expired job) | retry, remove | | done | finished; doneAction applies | remove | | error | failed, error carries the reason | retry, remove | | aborted | cancelled by the operator | retry, remove |

pending, uploading and processing count as active: a host that guards against leaving the page while work is in flight keys that guard on them.

License

hostApi.license?: Observable<LicenseDocument | undefined> — the host-aggregated license of the installation:

interface LicenseDocument {
  systems: LicenseSystem[];
  validity?: LicenseValidity; // the installation license itself
  subject?: string; // who the license is issued to, plain text
  serial?: number; // license serial number
}

interface LicenseSystem {
  system: string; // system-type key, e.g. 'middleware', 'vod', 'digital_signage'
  options: Record<string, LicenseOptionValue>;
  unknownOid?: number; // numeric OID suffix when `system` is 'unknown'
  validity?: LicenseValidity;
}

interface LicenseValidity {
  notBefore: string; // ISO 8601
  notAfter: string;
}

subject and serial identify the license itself — who it is issued to and its serial number — so a module can show them without asking the host's own API. subject is plain text, ready to render. Both are optional: a module that builds the document from its own narrower license API may not know them.

A licensed subsystem is present as an entry in systems; per-subsystem limits and switches are its options, keyed exactly as the license carries them ('Ds devices limit', 'Personal Accounts Limit', 'MICROS-Fidelio Support'). system is a plain string, not a union: a host may license a subsystem this package doesn't know yet, and 'unknown' plus unknownOid covers an OID the host itself doesn't recognize.

Prefer this channel over fetching your own copy, and keep the fallback for two cases: the field is absent (the host cannot serve the license — for instance the signed-in operator may not read it), or it is present but its value is still undefined (the host has no document yet). Gate on the value, not on the field, and both cases degrade the same way.

Module state channel

hostApi.setModuleState?(state: ModuleState) — the module's live self-description; the only source of the module's menu. See ModuleState reference. Re-send it whenever locale (titles), license, settings or feature flags change what the user should see. The field is optional on the host: feature-detect before calling.

Area broker

hostApi.areas?: AreaBroker — the receiver side of Area contributions. One broker instance is shared by all module roots on the host side. getTargetPages?() additionally tells a donor which receiving surfaces the shell currently has — see Receiving surface presence, and getContributionReasons?() tells a receiver about unsaved changes inside the contributions it renders — see Contribution unsaved changes.

ModuleState reference

interface ModuleState {
  pages: Array<{ path: string; icon?: string; title: string; domain?: string; order?: number; topLevel?: boolean }>;
  features: Record<string, boolean>;
  domains?: ModuleDomain[];
  title?: string;
  hasSettings?: boolean;
  hasFeatureFlags?: boolean;
  hasPermissions?: boolean;
  missingHostFeatures?: string[];
  cosmeticHostGaps?: string[];
  areaContributions?: AreaContribution[];
  areaTargets?: AreaTarget[];
  pending?: boolean;
}

The report is wire-shaped plain data: pre-localized strings, icons as SVG markup, no functions except through the handle.

Pages and menu domains

  • pages — the currently visible pages with localized titles; the host cannot localize module strings, so the module re-reports on locale change.
  • icon — plain SVG markup; the host inserts it as-is (there is no shared icon registry). Serialize your icon component/asset to SVG at report time.
  • domain — pages of all modules are grouped into shared menu domains (e.g. "Devices", "Monitoring") instead of one-section-per-module. The module declares its domains in domains (id, localized title, optional SVG icon, order) and tags pages with a domain id. Two modules declaring the same domain id merge into one group (first-reported title/icon wins).
  • order — one shared ordering scale across modules for pages within a domain group (and for domain groups themselves via ModuleDomain.order).
  • topLevel — draw the page as a standalone menu item alongside domain groups rather than inside one.
  • ModuleDomain.collapseInto — the id of another domain to move this group's single item into when the group ends up with one visible page, so a header never sits above a lone item. A module cannot decide this itself: whether its domain has neighbours depends on which other modules the installation has, and a module only knows its own pages. The host collapses only when the target group exists, otherwise the group is drawn as is — an item is never dropped. Declare the field on every declaration of the domain: the host merges domain metadata per field, first declaration wins.
  • features — the module's active capabilities (license features, settings, feature flags, permissions) as a flat boolean map; other modules' contributions may condition on them.
  • pending — the page list is preliminary, the data gating visibility (permissions, license) is still loading, see Preliminary reports.

Menu ordering scale

order is one scale, not one per kind: domain groups, topLevel pages and the host's own menu entries are sorted together, so order: 25 lands between a group at 20 and a group at 28 whatever those entries are. Pages inside a group are sorted by their own order on a second scale, local to the group.

Both scales are shared with the modules of the NetUP platform, and a value only means something relative to what is already there. The tables below are that reference — pick a free number between the neighbours you want, in steps that leave room for later insertions.

Groups, top-level pages and outgoing links:

| order | Entry | Owner | | ----- | -------------------------------- | -------------------- | | 0 | Overview (top-level) | host | | 5 | Overview / Dashboard (top-level) | mw, ds | | 8 | Accounts & Devices (top-level) | mw, ds | | 9 | link: DVB/HDMI/SDI input | host | | 10 | link: video stream processing | host | | 12 | iptv | mw | | 15 | vod (collapses into iptv) | mw, md, vod | | 20 | signage | ds | | 28 | Stream redundancy (top-level) | rs | | 31 | TV archive recording (top-level) | cu-admin | | 34 | Multicast failover (top-level) | multicast-failover | | 37 | orchestrator | orchestrator | | 40 | messages | mw | | 50 | monitoring | mw, ds | | 60 | settings | mw, ds, host | | 80 | Server settings | host | | 90 | help | every module |

Pages inside those groups:

| Group | Page | Owner | order | | --------------- | ---------------------------- | -------------- | ----- | | iptv | TV channels | mw | 10 | | | Radio channels | mw | 20 | | | EPG | mw | 30 | | | Channel groups | mw | 40 | | vod | Movies | md | 10 | | | Movies (legacy) | mw | 10 | | | TV series | md | 20 | | | VoD packages | mw | 30 | | | Tags | md | 40 | | | Content origins | md | 50 | | | VoD storage | vod | 60 | | signage | Media | ds | 10 | | | Playlists | ds | 20 | | | Layouts | ds | 30 | | | Schedules | ds | 40 | | | Widget data sources | ds | 50 | | | Import sources | ds | 60 | | orchestrator | Health | orchestrator | 10 | | | Instances | orchestrator | 20 | | | Inventory | orchestrator | 30 | | messages | In-room orders | mw | 10 | | | Emergency messages | mw | 20 | | | System messages | mw | 30 | | monitoring | Telemetry | mw | 10 | | | Statistics | mw | 20 | | | Statistics | ds | 40 | | settings | Clients settings | mw | 10 | | | System settings | mw | 20 | | | Transcoding | ds | 30 | | | Permissions | host | 40 | | | Settings | ds | 50 | | | Permissions | mw | 60 | | | Permissions | ds | 70 | | Server settings | Modules | host | 10 | | | Firmware | host | 20 | | | Backup | host | 30 | | | License | host | 40 | | | Network | host | 50 | | | Routing | host | 60 | | | Time | host | 70 | | help | API reference of each module | every module | 10…70 |

Two pages share a number on purpose when they are alternatives that never show up together: the mw "Movies (legacy)" takes the place of the md "Movies", so both sit at 10.

Several numbers above belong to pages a full installation does not draw at all — a page whose content arrives as a contribution to someone else's page while that receiver exists (Overview and Dashboard, the ds Accounts & Devices, Statistics and Settings — see Receiving surface presence), or a module's Permissions page superseded by the host's own. Their numbers still matter, because the hiding is conditional: where the receiver is missing — the other module is not installed, its page is gated off, the operator lacks the permission, the host is older and reports no such surface — the page comes back and sits next to pages of other modules, and without order its position would depend on which modules happen to be installed.

Order collisions

Equal values are not an error and produce no warning, so a predictable menu relies on unique ones:

  • equal order — the tie is broken by traversal order: the module with the lower ModuleManifest.order first (then by id lexicographically), the host's own entries last among equals;
  • no order — sorts last;
  • one domain id declared by several modules — the metadata is merged per field, first declaration wins, so a diverging title, icon or order silently loses to the earlier module. Declare a shared domain identically everywhere;
  • a page pointing at a domain it never declared — the host falls back to a per-module group and warns to the console;
  • same-titled items inside one group — the host adds the module name as a subtitle instead of reordering.

Preliminary reports

A module usually reports its state before the data that gates page visibility — permissions, license, feature flags — has arrived, so its first report carries fewer pages than the final one. Set pending: true while that data is loading and clear it once the state settles. The host relies on the flag twice:

  • it tells "this module has no such page" from "this module does not know yet", which decides whether a receiving surface counts as present, see Receiving surface presence;
  • it marks the shell as not ready yet. The shell of the NETUP host shows a loading indicator instead of the menu and the module content until every mounted module has settled, and picks the page to open from the settled menu — otherwise the operator sees an intermediate menu and lands on a page that is not the first one.

Clear pending even when the data fails to load. It reports "the state is not final yet", not "the request is in flight": a module that leaves the flag on after an error keeps the shell in its loading state for good. Set it to false as soon as the outcome is known, whichever it is — with a failed request the module reports whatever pages it can offer.

A module that never reports the field is treated as settled from its first report; degradation is cosmetic (capability 'moduleState.pending').

Module display name

title is the module name shown wherever the host labels the module itself — the subtitle under same-named menu items of different modules, the group header of a module that declares no domains, the settings-dialog tab, the module list of the "Application information" view. Like page titles it is pre-localized by the module and re-reported on locale change, which ModuleManifest.title cannot be: the manifest is read before mount, and the host has none of the module's translations.

Degradation is cosmetic (capability 'moduleState.title'): a host that ignores the field falls back to ModuleManifest.title, so keep the manifest title a readable name rather than an internal code. Report the field even when the two match — that costs nothing and keeps the name in one place once it changes.

Settings and version info

hasSettings / hasFeatureFlags tell the host to build a settings-dialog tab for the module; the tab content is rendered by the module through EmbeddedHandle.mountSettings. mountVersionInfo feeds the host's "Application information" view, which aggregates versions of the host and every module.

The version node is a <tbody> of the table the host draws for the module's section — the host's own rows (the module's contract version) live in a sibling body of that same table, which is what keeps every value in one column. So render table rows into it: a <tr> per version with a label cell and a value cell, and no wrapper element around them. A module whose own DOM sits between the node and its rows (its own <table>, a <div>, bare text) still shows the values, but they line up on their own instead of with the section — set display: contents on such wrappers to keep them out of the layout. Hosts predating this layout pass a <div>; rows rendered for a <tbody> degrade the same cosmetic way there.

Permissions editor fragment

A module that manages its own permission matrix reports hasPermissions: true and implements EmbeddedHandle.mountPermissions(node, role, context?) — the host embeds the module's per-role permissions editor into its role-management page. Degradation: an older host without the fragment slot simply shows its own binary per-module access toggle.

interface PermissionsEditorContext {
  ancestors: readonly string[];
}

context is an observable of what the host knows about the edited role: ancestors lists the roles it inherits from, expanded transitively, including ancestors the operator selected in the host's role form but has not saved yet. A module keys its matrix by role name and cannot resolve the host's role inheritance on its own, so an inheriting role would otherwise show an empty matrix while the host already grants it every ancestor permission.

It is observable rather than a plain value because the editor is live on both sides: the operator may pick another parent role while the module's own matrix holds unsaved edits, and re-mounting the fragment to deliver a new value would discard them. The host pushes the new value through the observable instead and re-mounts only on a role change.

Two rules make that work, and a host that breaks either takes back what the channel buys:

  • The observable object stays the same for as long as the fragment is mounted — a new value goes through subscribe, not through a new object. Handing the fragment a fresh object per render re-subscribes it and drops the unsaved edits the channel exists to protect.
  • get() returns a stable value while the ancestors have not changed. Expand the chain when pushing a new value, not inside the getter: a module that feeds get() into a cached-snapshot consumer (React's useSyncExternalStore and its equivalents) loops forever on a getter that builds a fresh array each call.

Three states are distinct. A missing context means the host predates the argument — resolve the ancestors from the platform's own role tree instead. A value of undefined means the host has not resolved them yet — the module waits rather than drawing conclusions. An empty array means the role inherits nothing.

Compatibility policy

CONTRACT_VERSION is the contract major and the only version number on the wire:

  • The host accepts a module only on exact major equality — checked twice: from the docker label before loading code, and from manifest.contractVersion after.
  • The major is bumped only on breaking changes: changed semantics or type of an existing field, a removal, a new required field. A breaking bump requires host and modules to be rebuilt against the new major — by design a rare event.
  • Additive extensions do not touch the version: a new field or method is declared optional (?) in the contract types, and the consuming side feature-detects at runtime. The plain-JS boundary makes the check trivial (hostApi.x === undefined).

Degradation directions:

  • Host newer than module — optional ModuleState fields are absent, new EmbeddedHandle methods are absent: the host gates on presence and, where the loss is functional, indicates "module requires an update".
  • Module newer than host — optional HostApi fields are absent: the module gates on presence, works degraded, and reports the contract paths of the features it actually needed in ModuleState.missingHostFeatures. The host shows a degraded-state warning ("shell requires an update") with the list. Two kinds of absence stay out of that warning:
    • a cosmetic loss — the feature still works, only its presentation differs (shell.setAppBarIcons, see App-bar icon scale) — never reaches the degraded warning: a module that reports such a gap reports it through cosmeticHostGaps, on the same grounds as the cosmetic misses of the report direction below;
    • an optimization channel — a field whose absence reproduces the behaviour the module had before the field existed, costing only the saving it enables (visible, see Visibility channel) — is feature-detected but never reported at all: a degraded warning would tell the operator to update a host that lacks nothing they can see.
  • Host omits an optional argument of a module method it does call (mountPermissions's context) — an optimization channel, like visible above: the module keeps the behaviour it had before the argument existed, so nothing is reported. It cannot be reported in any case: both report channels carry contract paths of HostApi / ModuleState fields, and a missing argument of a module's own method is not such a path. The module tells "the host passed nothing" apart from an empty value, and falls back to its own source of the data.
  • Host → module data (new fields inside license etc.) is not detectable; such extensions must be formulated so that ignoring them is harmless — otherwise the change is breaking.

State capabilities

The report direction ("module fills a new ModuleState field the host ignores") cannot be detected by the module alone — setModuleState returns nothing. The host therefore declares which post-major interpretation abilities it implements in HostApi.capabilities (ids are contract field paths, e.g. 'moduleState.pages.order'). The module-side check is detectMissingStateCapabilities(state, hostApi.capabilities) against STATE_CAPABILITY_REGISTRY:

  • functional misses (the feature visibly stops working) go to missingHostFeatures → degraded indication;
  • cosmetic misses (ordering, placement, labelling) go to cosmeticHostGaps and leave a console trace. They are deliberately kept out of missingHostFeatures: the degraded indication means "functionality is limited", and ordering losses would drown it in noise. A host reads cosmeticHostGaps in its "Application information" view — the module works, only its presentation degrades, and updating the host restores it.

cosmeticHostGaps carries the cosmetic misses of the consume direction too — an absent HostApi member whose loss is presentational only, reported by the module itself without a host capability (there is nothing to declare: the module sees the member missing). The id is the field path, as everywhere else: 'shell.setAppBarIcons'.

An absent capabilities field means the host predates the mechanism — nothing is confirmed. HostCapability also carries host-provided-replacement ids (e.g. 'unifiedPermissionsPage') that modules may use to hide duplicate pages when the host provides the unified equivalent.

Both report channels are themselves additive fields, so their own absence cannot be reported through them (that would recurse). They are therefore not registry entries but meta-cases: when a module has something to report and the host has not declared 'moduleState.missingHostFeatures' / 'moduleState.cosmeticHostGaps', the module logs the list to the console once — support is unconfirmed, not necessarily missing.

Area contributions

The generic mechanism for one module (the donor) to contribute UI — a tab, a toolbar button, a dashboard block — into a page owned by another module (the receiver), across isolated roots. The host is a broker only: it never interprets the content.

  • The donor declares contributions in ModuleState.areaContributions (metadata only): id, targetPage, slot, pre-localized title, optional order, visibleWhen, meta, requiredContext.
  • The receiver reads hostApi.areas.getContributions(targetPage), decides where each slot renders, gives the broker an empty DOM node and a live Observable<AreaContext>, and the broker routes the mount to the donor's EmbeddedHandle.mountArea(id, node, context). The donor's content stays in the donor's component tree (portal), and context changes stream through the observable without re-mounting.
  • visibleWhen is a declarative matcher evaluated by the receiver with matchesAreaContext: for every declared key the live context value must be in the allowed list (multiple keys AND, missing matcher → always visible, missing context key → hidden).
  • slot names, the AreaContext shape and meta keys are a pair-convention between the two modules — the contract and the broker do not interpret them. The receiver owns the convention.
  • Degradation is soft in every direction: no broker (hostApi.areas absent), no donors, or an older receiver simply mean the contribution does not appear.

Area pair compatibility

Pair conventions are strings, so a version skew between donor and receiver (a renamed slot, a dropped context key) would otherwise fail silently. Both sides therefore declare their half of the convention:

  • the receiver declares its accepting surface in ModuleState.areaTargets (page → slots, each slot optionally listing the contextKeys and metaKeys it supports);
  • the donor declares the context keys its component actually reads in AreaContribution.requiredContext (visibleWhen/meta keys are derived from the declaration itself).

detectAreaPairMismatches(states) — a pure function over (moduleId, ModuleState) pairs — reports unknown-slot, missing-context and missing-meta mismatches. Contributions to pages nobody declares are skipped (the receiver may be absent or predate the mechanism), and an undefined key list skips that aspect. The host runs it over live module states and shows mismatches as a degraded state; module authors are encouraged to run the same checker in CI over both sides' declarations.

Pair evolution rules: additivity first (new slots and keys are free; a donor must survive missing keys); when adding, release the receiver's declaration first; a rename or removal is breaking for the pair and must ship in lockstep on both sides — in the deployment window the checker will (by design) show the mismatch.

Receiving surface presence

A donor whose own page duplicates the receiver's page (its content now arrives as a contribution) hides that page from the unified menu — but only while the replacement actually exists in this shell. The receiver's page may be absent (the module is not installed at all) or hidden for this user (a feature flag, a missing permission), and hiding the duplicate then leaves the shell without the functionality.

  • The receiver drops an areaTargets entry whenever the page hosting the surface is not among the reported pages — a declared target therefore means "this surface is here and reachable".
  • The host aggregates the declarations and answers hostApi.areas.getTargetPages() with { pages, settled }; the donor hides its duplicate page when the targetPage id is in pages.
  • settled is false while a mounted module has not reported yet or reports ModuleState.pending: true (see Preliminary reports). A donor treats an unsettled snapshot as "replacement present" and keeps the duplicate hidden, so pages do not flash in and out while the shell fills up. Bound the wait: a module that never settles must not hide the donor's pages forever.
  • Degradation: an older host has no getTargetPages, so the donor hides nothing and the duplicate stays visible — cosmetic, never a loss of functionality.

Contribution unsaved changes

A contribution renders inside the receiver's tree, so the receiver's own navigation — its tabs, its inner links — takes the contribution down with whatever is unsaved in it. The donor's guard cannot help: setPreventUnload reports to the host, and the receiver is a different module with its own state.

  • The donor keeps reporting its reasons through shell.setPreventUnload as usual; nothing changes on the donor side.
  • The host answers hostApi.areas.getContributionReasons(targetPage) with the reasons of the donors whose contributions are currently mounted on that page. A declared but unrendered contribution reports nothing.
  • ShellPreventReason.id is unique within its own module, so the host makes the ids unique across the page before handing them over — prefix them with the donor id (ds:unsaved-form). A receiver lists the reasons side by side with its own and needs a stable key for each.
  • Changes are announced through the broker's subscribe, the same listener that already tracks declarations.
  • The receiver treats these reasons as read-only: it asks for confirmation before navigating away, but it must not clear them — they belong to the donor and go away when the contribution unmounts.
  • The host applies the same reasons to leaving the shell, so the drawer, the breadcrumbs and beforeunload are guarded without the receiver doing anything.
  • Degradation: an older host has no getContributionReasons, so the receiver navigates without asking — the behaviour it had before the channel existed. The loss is functional, not cosmetic, so a receiver that queries the channel reports it in missingHostFeatures (see Compatibility policy) instead of failing silently.

Receiving surfaces of the NETUP modules

A custom module can contribute into the pages below. overview is owned by the host itself, the rest by the mw module (IPTV Middleware), which is present in every installation. Declare targetPage and slot exactly as listed, plus the context keys your component reads in requiredContext.

| targetPage | Page | slot | Context keys | meta keys | | ------------------ | ----------------------- | ---------------------- | ------------------------------------------------------------------- | --------------- | | accounts-devices | Accounts & Devices | root-tab | — | — | | | | root-tab-toolbar | activeRootTab | — | | | | account-tab | accessCard, deviceId, accountMode | deviceCentric | | | | status-section-main | accessCard, deviceId, connectionId, isOnline, accountMode | — | | | | status-section-aside | same as status-section-main | — | | overview | Overview (landing page) | block | — (none) | — | | statistics | Statistics | root-tab | — | — | | system-settings | System settings | root-tab | — | tabIcon |

What the slots mean:

  • root-tab — a top-level tab of the page, rendered after the native ones; root-tab-toolbar (Accounts & Devices only) puts a control into the tab strip and is normally bound to its own tab with visibleWhen: { activeRootTab: ['<your-root-tab-id>'] }.
  • account-tab — a tab of the account detail view; status-section-main and status-section-aside are sections of its «Status» tab (main column and the column next to the screenshot).
  • block — a card on the overview page, the landing page of the shell. The host draws its own server indicators at order 1, 2, 70 and 80, and the NETUP modules contribute blocks at 10…60 on the same scale, so pick your order to place the block between them. The page hands out no context: a block fetches its own data and gates itself by its own permissions.

Context values worth knowing: accountMode is 'tv' | 'ds' | 'hybrid', so a contribution meaningful only for signage declares visibleWhen: { accountMode: ['ds', 'hybrid'] }.

meta keys are plain data read by the receiver: tabIcon takes SVG markup for the tab icon (a tab without it simply gets no icon), and deviceCentric (boolean) tells the receiver whether picking a device changes the tab content, which decides if the device sidebar is shown; omitting it means «not device centric».

Every key is optional on both sides, and the surface only grows: an unfilled slot or an unread context key is never an error. Run detectAreaPairMismatches in your CI against the declarations above to catch a skew before deployment.

Examples

Minimal module (no framework)

// src/manifest.ts
import { CONTRACT_VERSION, EmbeddedHandle, ModuleManifest } from '@netup/unified-admin-contract';

const manifest: ModuleManifest = {
  id: 'acme',
  contractVersion: CONTRACT_VERSION,
  title: 'ACME',
  mount(container, hostApi): EmbeddedHandle {
    const root = document.createElement('div');
    container.appendChild(root);

    let page = '/';
    const render = () => {
      root.textContent = `ACME ${page} — theme: $