@web4.0/adm
v1.0.1
Published
Portable declarative UI composition engine with a React adapter.
Readme
@web4.0/adm
ADM (Administrator) is a declarative UI composition engine. You provide the component implementations and their definitions; ADM validates a serializable configuration, composes the React tree, and offers scoped instance lookup and event communication. It does not provide UI components, styling, state management, or business logic.
Install
npm install @web4.0/adm
npm install your-ui-component-packageReact 18 or newer is a peer dependency. The core can also be imported separately without loading the React adapter:
import { ADMCore } from "@web4.0/adm/core";Define and register components
The registry pairs a component implementation with the properties, events, and child behavior supported by that component.
import React, { useEffect, useState } from "react";
import ADM from "@web4.0/adm";
function ProductList({ adm, products }) {
return (
<ul>
{products.map(product => (
<li key={product.id}>
<button onClick={() => adm.emit("onSelect", { product })}>
{product.name}
</button>
</li>
))}
</ul>
);
}
function ProductDetails({ adm, source }) {
const [selected, setSelected] = useState(null);
useEffect(() => {
const list = adm.query("ProductList", source);
if (!list) return;
return list.listen("onSelect", ({ product }) => setSelected(product));
}, [adm, source]);
return <article>{selected?.name ?? "Select a product"}</article>;
}
function Page({ children }) {
return <main>{children}</main>;
}
const components = {
Page: {
component: Page,
children: true
},
ProductList: {
component: ProductList,
props: {
products: { type: "array", required: true }
},
events: {
onSelect: { payload: "object" }
}
},
ProductDetails: {
component: ProductDetails,
props: {
source: { type: "string", required: true }
}
}
};
ADM.registerComponent(components);An omitted children declaration means children are unsupported. Property types
are string, number, boolean, object, array, and null. Defaults are
applied when a property is omitted, while explicit null remains distinct from
an omitted property. Unknown properties are rejected. adm is reserved for the
runtime and cannot be defined in the registry or application properties.
Compose and render an application
Configurations contain only serializable values and component names. Event handlers and functions belong in component implementations, not in the configuration.
const appConfig = {
name: "Page",
selector: { id: "home" },
children: [
{
name: "ProductList",
selector: { id: "products", className: "catalog" },
props: {
products: [
{ id: "keyboard", name: "Keyboard" },
{ id: "mouse", name: "Mouse" }
]
}
},
{
name: "ProductDetails",
props: { source: "products" }
}
]
};A component that renders nested ADM components must declare children: true.
ADM recursively passes their React elements in props.children. Selectors are
runtime identities; className does not apply styling unless it is separately
declared as a component property.
Configuration registry and linked configs
Each ADM instance owns a config registry, parallel to its component registry.
Page configs remain normal component trees. A component prop can reuse a
registered value with type: "linked_config" and a value naming the registry
entry. Links are prop values, not page roots or child nodes. Registry entries
are resolved lazily when a page is rendered, so component configs may refer to
other entries registered later.
import { createADM } from "@web4.0/adm";
const adm = createADM();
adm.registerComponent(components);
adm.registerConfig({
"layout.options": {
type: "data_config",
value: {
type: "object",
value: { theme: "light", compact: false }
}
},
"layout.header": {
type: "component_config",
value: { name: "Header", props: { title: "Dashboard" } }
}
});
const pageConfig = {
name: "PageFrame",
props: {
options: {
type: "linked_config",
value: "layout.options",
override: { compact: true }
},
header: { type: "linked_config", value: "layout.header" }
},
children: [{ name: "ProductList", props: { products: [] } }]
};Declare options as an object prop and header as a component prop on
PageFrame. Data configs use a typed { type, value } declaration; a linked
data config resolves to its serializable value. A linked component config is
compiled by ADM and passed to the receiver as a React element, using the
registered component implementations. Overrides merge recursively by object
path; arrays are replaced as a whole and unknown override paths are rejected.
Missing targets, type mismatches, and circular links fail explicitly.
Interface routes and reusable configurations
Resolve a page within its interface's page list, then pass the normalized route
context and interface-level reusable configurations to render. Route values
remain strings, repeated query keys become arrays, and declared query defaults
are applied before the page is rendered.
import { createADM, resolvePageRoute } from "@web4.0/adm";
const adm = createADM();
adm.registerComponent(components);
const pages = [
{
name: "Profile",
route: "/users/:userId",
queries: { tab: { default: "overview" } },
config: {
name: "ProfilePage",
props: {
userId: { $ref: "route.params.userId" },
activeTab: { $ref: "route.query.tab" }
},
children: [{ $config: "MainFooter" }]
}
}
];
const { page, context } = resolvePageRoute(pages, window.location.href);
const interfaceConfigs = {
MainFooter: {
name: "Footer",
props: { copyright: "Example" }
}
};
adm.render(page.config, {
route: context,
reusableConfigs: interfaceConfigs
});$ref supports route.path, route.params.<name>, and
route.query.<name>. A reference may provide a serializable default; missing
values without defaults fail with a path-specific error. $config references
resolve by name within the active render context, may be nested, and reject
unknown or circular references. Reusable configurations are passed to each
render rather than added to a shared global registry. Component implementations
can inspect the same normalized route through adm.getRouteContext(), which
returns a copy so a component cannot mutate the runtime's route state.
resolvePageRoute matches only the supplied interface page list, prefers more
specific static routes over parameter routes, and rejects equally specific
ambiguous matches. Query values are not coerced; URL-derived values remain
strings unless repeated, in which case they are arrays of strings.
Portable interface artifacts
createInterfaceArtifact assembles an external Identity service reference,
interface, page, reusable-config, and selected UI-family records into a JSON-serializable
application/vnd.web4.adm-interface+json document. It fails if an interface
selects a family version that is missing from the supplied registry records.
The artifact contains package references and manifests only; it does not
install or execute packages.
import { createInterfaceArtifact } from "@web4.0/adm";
const artifact = createInterfaceArtifact({
service: {
service_uri: "identity://services/service-123"
},
interfaceDoc,
pages,
configs,
families
});Shorthand and typed values
Use ordinary JSON values throughout page configs; ADM validates component props
against the registry's property schema. Explicit { type, value } wrappers
remain available when a value needs an explicit ADM type.
{
name: "Workspace",
props: {
workspace: "workspace",
settings: { year: 2025 }
}
}ADM only interprets an exact { type, value } envelope when type is one of
its supported value types (string, number, boolean, object, array, or
null). Application objects with other type values—including objects that
also have a value field—remain ordinary data.
API-backed component properties
A declared component property can use a serializable API source instead of a
static value. ADM starts the request when its component tree mounts, validates
the response against the component property's declared type, and updates that
prop in the live component instance. API configs declare the backend domain,
API version, call path, and optional request/auth/response settings. For
path-based calls ADM constructs {domain}/api/{version}/{path} (version
defaults to v1) and sends the version in x-api-version; calls default to
JSON POST. An explicit url can be used when an API does not follow that
route pattern.
adm.registerAuthProvider("session", async ({ signal }) => {
const token = await getCurrentAccessToken({ signal });
return { Authorization: `Bearer ${token}` };
});
adm.registerConfig({
"accounts.services": {
type: "api_config",
value: {
path: "/get_services",
domain: "https://api.example.com",
version: "v1",
auth: { provider: "session" },
initial: []
}
}
});
const accountsPage = {
name: "AccountsDashboard",
props: {
copy: { title: "Accounts", description: "Manage services." },
services: {
type: "api",
target: "accounts.services"
}
}
};
const removeListener = adm.listen("dataError", ({ component, property, message }) => {
console.error(`Could not load ${component}.${property}: ${message}`);
});
await adm.requestApi("accounts.services", { body: { page: 1 } });
await adm.refreshDataSource("AccountsDashboard", "accounts-dashboard", "services");registerAuthProvider keeps credentials in the host application's runtime;
configs only reference the provider by name. responsePath is optional and
defaults to data, matching the { ok, message, status_code, data } API
envelope; set it to a dotted path such as data.services, or to the empty
string to select the whole response. url remains supported for existing
configs. API descriptors also support method, credentials, public string
headers, JSON body, auth, and an initial value. acceptNonOk: true
returns unsuccessful envelopes as { status_code, message, data } instead of
throwing; otherwise a failed HTTP response or ok: false envelope rejects with
the backend status_code exposed as error.code and error.status_code.
Registered API configs can be called with requestApi(name, { body }), and
API-backed properties can be reloaded with refreshDataSource(...).
Authorization and cookie headers cannot be embedded in config; cookie-based
auth can use credentials: "include" when appropriate.
ADM emits dataLoading, dataLoaded, and dataError events with the component,
selector, and property. Errors are also logged; a failed request leaves the
initial/current prop value intact. Requests are aborted when their source
changes or their rendered scope unmounts. Only the React runtime performs
requests; serializing or validating a config does not make a network call.
Sandboxed page previews
The browser preview helpers are exported from @web4.0/adm:
createPagePreviewDocument, getPreviewApiSources, and
createPreviewRequestHandler. The document loads the selected UI-family
release in a sandboxed iframe. API configs inside the iframe contain only an
opaque proxy URL and source ID; the host handles matching requests with its ADM
runtime so credentials and real API URLs stay outside the frame. The host must
attach the request handler to its message event and provide the
/api/adm-preview-runtime and /api/ui-family-package routes. Unlisted API
requests and network connections remain blocked.
Next.js App Router
Runtime selectors and event subscriptions are client-side behavior. Call
ADM.render from a Client Component and keep the component registry and
configuration in that client-side module:
"use client";
import ADM from "@web4.0/adm";
import components from "./components";
import appConfig from "./app.config";
ADM.registerComponent(components);
export default function Application() {
return ADM.render(appConfig);
}Only the composed tree needs to be a client component; surrounding Next.js layouts and pages can remain Server Components. Component implementations may use React hooks normally. Component event listeners should be subscribed from an effect and return ADM's unsubscribe function so cleanup follows React's lifecycle.
Selectors and events
The adm property is injected into every registered component. Its query and
getInstance methods return an instance handle or null if there is no
accessible match. A string selector matches selector.id; an object selector
matches its supplied id and/or className. Queries are limited to the
current rendered application scope. A query matching multiple instances throws
an ambiguity error; use unique selectors when a component must be addressed.
Separate rendered application scopes and ADM instances do not share instances
or event listeners.
const list = adm.query("ProductList", "products");
const unsubscribe = list?.listen("onSelect", payload => {
console.log(payload.product);
});
// From the ProductList implementation:
adm.emit("onSelect", { product });An instance handle also exposes listen, unlisten, and emit. Events must be
declared in the publisher's registry definition. If an event declares a
payload type, ADM validates each emitted payload before notifying listeners.
listen returns an idempotent unsubscribe function. ADM removes instance
listeners when their components unmount.
UI-family registries and component manifests
When an interface uses more than one UI family, register each family under a
stable namespace and reference components by namespace::ComponentName. This
keeps component resolution deterministic even when packages export the same
local component name.
adm.registerComponent(webRegistry, { namespace: "web" });
adm.registerComponent(chartsRegistry, { namespace: "charts" });
const page = {
name: "web::Page",
children: [
{ name: "charts::Chart", props: { data: [] } }
]
};createComponentManifest(registry) creates a technical manifest from the
registry. UI-family packages may enrich it with descriptions, usage guidance,
categories, and examples. validateComponentManifest(registry, manifest)
rejects documented components, props, defaults, events, or child support that
do not match the executable registry.
Independent ADM instances
Use the default export for a single application. Create separate instances when applications need independent registries and runtime scopes:
import { createADM } from "@web4.0/adm";
const admin = createADM();
admin.registerComponent(components);registerComponent(registry, { replace: true }) explicitly replaces existing
definitions. Registration is validated atomically, and duplicate names are
otherwise rejected.
Package development
npm install
npm test
npm run buildThe package ships native ES modules and TypeScript declarations. The React
adapter is exported from the main entry point; @web4.0/adm/core exposes the
React-independent registry and runtime primitives.
