@meister1/app-bridge
v0.2.2
Published
Host↔app bridge contract and runtime for embedding MX apps into a MeisterSystems host (shadow-DOM custom elements, token relay, capability gating).
Downloads
539
Readme
@msys/app-bridge
The shared contract and runtime for embedding first-party MX apps into Meister Systems via
shadow DOM. One rule: an MX app is a custom element that renders into its own shadow root
and talks to whoever mounted it through a single injected MsysHost object. The app must
not care whether that host is Meister Systems, the mx-shell dev shell, or a test harness.
Entry points
| Import | Contents | Consumers |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| @msys/app-bridge/contract | MsysHost, MsysContext, MsEntityRef, AppManifest + Zod schema, AppLoadError, Result — types + zod only | everyone |
| @msys/app-bridge/app | MsAppElement, dual-mode auth session, React hooks, in-shadow portals, axios interceptors | MX apps |
| @msys/app-bridge/host | loadApp, mountApp, createMsysHost | ms-platform, dev shell |
| @msys/app-bridge/vite | createAppConfig — single-file IIFE bundle + mx-app.manifest.json | app build configs (monorepo-internal, not published) |
Private package scope. The published package ships only
./contract,./app, and./host(bundled todist/). The./vitebuild helper and theexamples/at the end of this file are monorepo-internal and excluded. A host integrator needs only./host("Hosting an app" below); the app-authoring sections are for building apps inside this repo.
Installing it outside this repo
The package is published to the private AWS CodeArtifact registry (domain msys,
repository msys-npm) — the same registry the services already resolve @msys packages
from. It is never published to npmjs.
npm run npm:login # aws codeartifact login --namespace=@msys --tool npm \
# --repository msys-npm --domain msys --domain-owner 579466223539 --region eu-central-1
npm i @msys/app-bridgedist/ is self-contained: @mx/auth and @msys/process-shared are bundled in at build
time (build.mjs), because both are private, unpublished workspace packages that do not
exist off-workspace — @msys/process-shared has no build at all. Only zod (a real
dependency) and the optional peers react, react-dom, axios, oidc-client-ts are
resolved from the consumer's own tree. scripts/verify-package.mjs fails the build if that
ever stops being true; releases run it before publishing.
Versions in CodeArtifact are immutable — bump version in packages/app-bridge/package.json
before releasing. The package version is independent of MSYS_HOST_CONTRACT_VERSION, which
governs host↔app compatibility.
Building an embeddable app (monorepo-internal)
// src/msEntry.tsx — the bundle entry
import { MsAppElement } from "@msys/app-bridge/app";
import styles from "./styles.css?inline";
import { App } from "./App";
class MxMyAppElement extends MsAppElement {
protected getStyles() {
return `${super.getStyles()}\n${styles}`;
}
protected renderApp() {
return <App />;
}
}
customElements.define("mx-my-app", MxMyAppElement);// vite.ms-entry.config.ts
import { createAppConfig } from "@msys/app-bridge/vite";
export default createAppConfig({
appRoot: __dirname,
entry: "src/msEntry.tsx",
elementTag: "mx-my-app",
fileName: "mx-my-app",
});Inside the app, everything comes from hooks:
const host = useHost(); // navigation, environment
const { organisationId, locale } = useMsysContext(); // re-renders on live changes (locale)
const { session, state } = useAuthSession(); // dual-mode: host-backed or standalone OIDC
const portalContainer = usePortalContainer(); // ALL overlays render hereAPI clients attach auth with attachAuthInterceptors(axiosInstance, session) — this sets
Authorization + X-Msys-Graphql-Token on API-origin requests only and owns the
401-refresh-replay loop with a circuit breaker.
Standalone mode (running the app at its own URL with a direct Keycloak login) needs no host:
createAuthSession({ standalone: { authority, clientId } }). Mode selection is total — a
session created with neither host nor standalone options fails fast; frame sniffing
(window.self !== window.top) is banned in this package.
Styles: inline sheet vs. manifest css
Two ways an app's CSS reaches its shadow root, and they compose:
getStyles()— the app inlines its sheet as a string (import css from "./x.css?inline"). Works with any host.- manifest
css—createAppConfigwrites the build's extracted CSS asset intomx-app.manifest.json;loadAppresolves it against the manifest URL (same allow-list and origin rule asjs) andmountApp/mountWidgetprepend a<link rel="stylesheet">into the shadow root. The host owns this injection — an app bundle needs no code for it, which is what makes already-shipped bundles styleable. Seedocs/decisions/2026-07-30-manifest-css-host-injection.md.
Doing both is harmless (identical rules, and the inline sheet stays last so it wins ties).
Widgets
An app's manifest may declare inline widgets — extra custom elements its bundle registers,
mounted at code locations via mountWidget (inert navigation; auth/context identical to page
mounts). Declare them in createAppConfig({ widgets: [{ name, elementTag }] }); hosts look the
element tag up by name from manifest.widgets. First consumer: billing's
mx-billing-status / mx-billing-plan-picker / mx-billing-usage-meter inside
mx-call-dashboard.
Tabs / multiple surfaces
A host tab bar is just several surfaces mounted side by side — one per container, each in its
own shadow root with its own MsysHost. Two ways a host obtains the surfaces to mount:
- Declared widgets (above) — one manifest lists many
widgets; the host mounts each bynameviamountWidget. - Per-element manifests — one bundle registers several tags, each published as its own
<tag>.manifest.json; the hostloadApps each manifest URL and mounts by tag.
Several manifests can point at one shared bundle. loadApp injects that bundle only once,
but every call returns the requested manifest's own elementTag/css — so
mountApp(container, loaded.value.elementTag, host) always mounts the surface you asked for
(a manifest tag the bundle never defines settles as a clean timeout). To render one app's
internal pages as tabs in a single mount, switch the active view with pushPath and reflect
the app's own route changes via reportRouteChange (no remount needed).
Hosting an app
import { createMsysHost, loadApp, mountApp } from "@msys/app-bridge/host";
import { isErr } from "@msys/app-bridge/contract";
const loaded = await loadApp("https://app.example/mx-app.manifest.json");
if (isErr(loaded))
renderErrorPanel(loaded.error.kind); // network | invalid-manifest | contract-mismatch | origin-mismatch | script-error | timeout
else {
const { host, updateContext } = createMsysHost({
getAuthHeaders: async () => ({ authorization: `Bearer ${await getAccessToken()}` }),
onAuthDead: () => redirectToLogin(),
context: { organisationId, userId, locale, msysBaseUrl } /* current MsysContext */,
navigation: { basePath: "/apps/my-app", initialPath: "/" },
navigate: (target) => router.push(target) /* { path } | MsEntityRef */,
reportRouteChange: (path) => router.replace(joinBasePath("/apps/my-app", path)),
environment: { isNative: false, hostVersion: "1.0.0", deployEnv: "prod" },
});
const mounted = mountApp(container, loaded.value.elementTag, host);
if (isErr(mounted)) renderErrorPanel(mounted.error.message);
// else: locale changes: updateContext({ locale }); org/user changes: remount the element.
}Host rules: the bundle origin must match the manifest origin (opt-outs via
allowedBundleOrigins only); navigate({ path }) values are route paths — join them under
basePath, never feed them to window.location; remount the element when organisationId
or userId changes.
Shadow-app rules (what an embedded app must never do)
- No
document.bodyportals. MUI/Radix overlays default to body — always passusePortalContainer(). Styles do not exist outside your shadow root. - No
window.history. The host owns the URL. Run a memory router internally, callhost.navigation.reportRouteChange(path)on route changes, and start fromhost.navigation.initialPath. - No token persistence. Embedded tokens are memory-only; never write them to storage, attributes, or logs. The session enforces this — don't work around it.
- Clean up everything in effects: document/window listeners, timers, observers.
disconnectedCallbackunmounts your React root, but globals you registered survive it. - Bundle your own dependencies. The IIFE build ships your React/UI libs; never assume host globals.
Example & harness
examples/hello-app exercises the full contract (context display, entity navigation,
authenticated fetch, in-shadow portal), and examples/hello-app/harness is a hand-built host
page with a fake token issuer, log sinks, locale switcher, and org-switch remount:
pnpm run example:build # dist/hello-app.js + mx-app.manifest.json
pnpm run example:serve # harness on http://localhost:5470