feat: let one module contribute a browser renderer to another's capability
The platform has had a complete story for one module using another's
*backend* since ADR 0006 — capabilities, versioned ranges, provider
registration. It has had none at all for one module using another's *browser*
code, and every workaround is wrong in a way that matters: a consumer cannot
import a peer module, it cannot re-implement a renderer for a domain it does
not own, and a second `entrypoints` role would put the platform in charge of
deciding when a slot inside someone else's screen should be filled.
Add a generic browser contribution. `manifestVersion: 7` declares one:
namespaced id, its own version, a target capability, a compatible range, and
one artifact. That is the complete list, and the omissions are the decision —
no payload schema, slot, ordering, or rendering hint exists anywhere, so Core
cannot learn what a slide is because the declaration cannot express it.
Discovery resolves a contribution artifact through the *identical* loop every
other compiled entrypoint uses, inheriting the path, containment, symlink,
extension, and file-kind checks rather than getting a weaker copy of them. The
registry publishes one only when another eligible module provides the target
capability within the declared range; otherwise it is absent rather than
mounted and broken. `apps/api` serves it as a sibling of the artifact and
display routes, with the same policy: three lookup keys, one exact match, one
already-validated path, and one indistinguishable 404 for unknown, undeclared,
stale, and incompatible alike.
A consumer discovers through the host, never through a URL. Its descriptor
carries identity, version, and capability; the URL stays server-side and is
resolved exactly as `api.request("records")` is. A module can only ever see
contributions aimed at a capability it itself provides, and cannot name its
way to anything else — a mount for a withheld id fails without fetching.
The consumer owns the domain contract entirely. Input is opaque JSON, checked
structurally so a Date, a function, a cycle, or `undefined` fails at the
boundary rather than vanishing somewhere far from the mistake, and checked for
nothing else. A contributed renderer receives its own id and that input: no
API client, realtime, principal, configuration, database, storage, or
credential. Loading one grants its module nothing.
The contribution ABI is versioned separately from the module UI ABI, because
the two have different shapes and lifecycles and coupling them would force
every module to rebuild whenever either moved. It supports asynchronous
readiness, update-without-remount (a screen that remounted per tick would
restart animations and reload media), deterministic cleanup in a `finally`,
and sanitized failure that never carries the consumer's domain data. Each
renderer mounts in its own isolated root with its own stylesheet.
The template now declares a contribution, so `pnpm build` exercises the whole
authoring path, and the shell suite drives the full relationship — one module
discovering, mounting, updating, and unmounting another's React renderer with
neither importing the other.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
07835b7b56
commit
c444ff570b
@@ -0,0 +1,193 @@
|
||||
# 0041: One module may contribute a browser renderer to another module's capability
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-27
|
||||
- Supersedes: none
|
||||
- Related: [ADR 0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
|
||||
[ADR 0021](0021-modules-as-hostable-products.md),
|
||||
[ADR 0022](0022-module-ui-authoring-and-host-contract.md),
|
||||
[ADR 0024](0024-browser-safe-ui-sdk-and-host-loader.md),
|
||||
[ADR 0025](0025-trusted-module-ui-asset-publication.md),
|
||||
[ADR 0033](0033-display-kiosk-host-contract.md),
|
||||
[ADR 0040](0040-module-stylesheets-and-artifact-isolation.md)
|
||||
|
||||
## Context
|
||||
|
||||
The platform already has a complete story for one module using another's *backend*: capabilities,
|
||||
versioned dependency ranges, and provider registration (ADR 0006). It has no story at all for one
|
||||
module using another's *browser* code, and the first real product needs one.
|
||||
|
||||
The concrete shape, stated so the mechanism can be judged against it rather than against an
|
||||
abstraction:
|
||||
|
||||
```text
|
||||
Signage provides a backend capability and owns a screen made of timeline slots
|
||||
Announcements requires that capability and registers a backend provider for it
|
||||
Announcements also ships a React renderer that knows how an announcement should look
|
||||
Signage mounts that renderer for one slot, drives it, and takes it down
|
||||
```
|
||||
|
||||
Every alternative available today is wrong in a way that matters. Signage cannot import
|
||||
Announcements: modules are independently installed, independently versioned peers, and
|
||||
`pnpm boundaries` rejects it outright. Signage cannot re-implement the renderer: it does not own the
|
||||
domain and the two would drift on the first change. Announcements cannot declare a second primary UI:
|
||||
`entrypoints.ui` and `entrypoints.display` are artifacts a *host* mounts in surfaces the host owns,
|
||||
and nothing about "a slot inside another module's timeline" is a host concern. And the platform must
|
||||
not learn what a slide is — the moment Core has a `slide` type, every future consumer inherits
|
||||
Signage's vocabulary.
|
||||
|
||||
## Decision
|
||||
|
||||
Add a generic **browser contribution**: a compiled renderer one module declares for a capability
|
||||
another module provides, mounted by that other module inside a container it owns.
|
||||
|
||||
### The manifest declaration is inert and small
|
||||
|
||||
`manifestVersion: 7` adds a top-level `contributions` array. Each entry declares:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `id` | `<module-id>.<local-name>` — namespaced, so uniqueness needs no registry |
|
||||
| `version` | The contribution's own version, independent of its module's |
|
||||
| `capability` | The capability it plugs into, owned by whichever module provides it |
|
||||
| `capabilityRange` | The provider versions it is compatible with |
|
||||
| `artifact` | One compiled browser artifact, package-relative |
|
||||
|
||||
That is the complete list, and the omissions are the decision. There is no payload schema, no slot,
|
||||
no ordering, no rendering hint, and no domain vocabulary of any kind. **Core cannot learn what a
|
||||
slide is from this declaration, because the declaration cannot express it.**
|
||||
|
||||
Validation is static and fails closed: the id must be namespaced to the declaring module and have
|
||||
exactly one local segment; ids and artifact paths must be unique within a module; the artifact must
|
||||
not collide with any entrypoint; and a contribution may not target a capability its own module
|
||||
provides — a module contributing to itself needs no platform mechanism and would make the
|
||||
compatibility check a comparison of a module with itself.
|
||||
|
||||
Version 7 is a hard cutover, exactly as every prior manifest version has been (ADR 0006, 0014, 0021,
|
||||
0033). Upgrading a module that contributes nothing is a one-character edit.
|
||||
|
||||
### Publication is the existing mechanism, extended
|
||||
|
||||
Discovery resolves each contribution artifact through **the identical loop** every other compiled
|
||||
entrypoint uses, so a contribution inherits the path grammar, containment, symlink-resolution,
|
||||
extension, and file-kind checks rather than receiving a second, weaker implementation of them. Two
|
||||
contributions that resolve through a link to one file are rejected.
|
||||
|
||||
The publication registry adds contributions on three conditions, all of which must hold: the
|
||||
contributing module is in the eligible set, *some other* eligible module provides the target
|
||||
capability, and that provider's declared capability version satisfies the declared range. A
|
||||
contribution that fails any of them is **absent** — never published-but-broken, and never mounted
|
||||
with a shrug.
|
||||
|
||||
`apps/api` serves it at `GET /api/v1/module-ui/<module-id>/<version>/contributions/<contribution-id>`
|
||||
— a sibling of the artifact and display routes, under the same prefix, with the same policy: three
|
||||
lookup keys, one exact registry match, one already-validated canonical path, no request value ever
|
||||
composed into a filesystem path, and one indistinguishable `404` for unknown, undeclared, stale, and
|
||||
incompatible alike. Source maps, sibling chunks, and package-root files stay unreachable by
|
||||
construction.
|
||||
|
||||
### A consumer discovers through the host, never through a URL
|
||||
|
||||
The consuming module's descriptor gains a `contributions` array: identity, version, capability, and
|
||||
the **server-produced** URL. A module UI sees only the first three; the URL lives in the host, which
|
||||
resolves it exactly as it resolves `api.request("records")`. A module that built
|
||||
`/api/v1/module-ui/<id>/<version>/contributions/<id>` itself would be welded to Ekklesia's private
|
||||
deployment detail and could never run under a standalone host.
|
||||
|
||||
`ModuleUiHostContext` gains a sixth member, `contributions`, with `list(capabilityId)` and
|
||||
`mount({ contributionId, container, input })`. A consumer can only ever see contributions aimed at a
|
||||
capability **it** provides, because that is the only thing the server put in its descriptor — and it
|
||||
cannot name its way to anything else, because a `mount` for an id the server withheld fails without
|
||||
fetching anything.
|
||||
|
||||
### The consumer owns the domain contract, entirely
|
||||
|
||||
`input` is opaque JSON. The platform validates that it *is* genuinely JSON-serializable — structurally,
|
||||
so a `Date`, a function, `undefined`, a non-finite number, or a cycle fails at the boundary rather
|
||||
than vanishing somewhere far from the mistake — and validates nothing about its shape or meaning. A
|
||||
consumer publishes its own input contract alongside its capability and versions it through its own
|
||||
capability version. Neither the manifest, the registry, the route, nor the SDK has a place to put a
|
||||
schema, which is what stops one from appearing.
|
||||
|
||||
### The contribution ABI is separate and versioned
|
||||
|
||||
`MODULE_CONTRIBUTION_ABI_VERSION` is stamped by `defineModuleContribution`, never written by an
|
||||
author. It is deliberately **not** the same number as `MODULE_UI_ABI_VERSION`: the two contracts have
|
||||
different lifecycles — one receives a host context, the other receives a consumer's input and can be
|
||||
updated in place — and versioning them together would force every module to rebuild whenever either
|
||||
moved. A mismatched artifact fails with its own code before mounting.
|
||||
|
||||
The ABI supports exactly what a long-running screen needs:
|
||||
|
||||
- **asynchronous readiness** — `mount` may return a promise, so a consumer can sequence a transition
|
||||
instead of guessing when a renderer has decoded its media;
|
||||
- **update without remounting** — remounting per change would destroy DOM, restart animations, and
|
||||
reload media on every tick. A renderer that genuinely cannot update in place omits `update`, and
|
||||
the consumer is told to remount rather than left rendering stale content behind a silently ignored
|
||||
call;
|
||||
- **deterministic cleanup** — the loader runs the renderer's cleanup and removes its isolated root in
|
||||
a `finally`, so a renderer that fails to clean up still has its styles taken out of the page;
|
||||
- **sanitized failure** — every code is fixed, and no message, stack, or `cause` ever carries the
|
||||
consumer's domain data.
|
||||
|
||||
Each contribution mounts inside its own Shadow DOM root (ADR 0040) with its own compiled stylesheet,
|
||||
so a contributed renderer cannot restyle the consuming module's screen around it, and that module's
|
||||
rules cannot reach in.
|
||||
|
||||
### What a contribution is not given
|
||||
|
||||
No database, no object storage, no credential, no session, no API client, and no reach into any
|
||||
module — including the one that contributed it. Its context is two members: its own id and the
|
||||
consumer's input.
|
||||
|
||||
**Loading a contribution grants its module nothing.** Server-side authorization is unchanged and
|
||||
remains the only authority. Everything a contributed renderer needs arrives through the consumer's
|
||||
input, because the consumer is the party that was authorized to obtain it.
|
||||
|
||||
A contribution artifact is still installed, trusted, first-party code in the host's realm (ADR 0022,
|
||||
ADR 0024). Its isolated root is a correctness boundary for styles and must never be described as a
|
||||
sandbox.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One module can extend another's screen without either importing the other, and without the
|
||||
platform learning either one's vocabulary.
|
||||
- The Dashboard bundle now contains two dynamic imports rather than one, both the generic loader's:
|
||||
a module UI artifact and a contributed renderer.
|
||||
- Manifest version 7 requires every module to update one field. Nothing else changes for a module
|
||||
that contributes nothing.
|
||||
- `ModuleUiHostContext` grew from five members to six. It is present on every context and empty for
|
||||
the overwhelming majority of modules, so a module writes the same code whether or not anything was
|
||||
contributed — and a standalone host running one module supplies an empty implementation that is
|
||||
the *correct* one rather than a stub.
|
||||
- A consuming module must handle an empty list: a contribution can be absent because nobody
|
||||
contributed, because the contributor is disabled, or because it is outside the compatible range.
|
||||
That is the same discipline an optional capability dependency already requires.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Let the consumer import the contributor.** Rejected: it is the layer violation the boundary
|
||||
checker exists to prevent, and it would make two independently installable modules one unit.
|
||||
- **Put the renderer in the consumer.** Rejected: the consumer does not own the domain, so the
|
||||
renderer and the data it draws would drift on the first change to either.
|
||||
- **A second `entrypoints` role.** Rejected: the two existing roles are artifacts a *host* mounts in
|
||||
surfaces the host owns. A contribution is mounted by a module, at a moment and in a place only that
|
||||
module understands; calling it an entrypoint would put the platform in charge of deciding when a
|
||||
slot should be filled.
|
||||
- **A platform-defined payload schema.** Rejected, emphatically. It would make Core the owner of a
|
||||
vocabulary it cannot possibly own, force every future consumer to inherit the first one's shape,
|
||||
and require a platform release to change a domain contract between two modules.
|
||||
- **Give the contribution its own API client.** Rejected: a renderer with its own authority is a
|
||||
privilege the consumer never granted, and a second authorization path to reason about. The consumer
|
||||
was authorized to fetch the data; it passes what it fetched.
|
||||
- **Let a consumer construct the artifact URL.** Rejected for the same reason a module may not build
|
||||
its own API prefix (ADR 0022): it welds the module to Ekklesia's deployment detail.
|
||||
- **One shared ABI version for UI artifacts and contributions.** Rejected: it couples two contracts
|
||||
with different shapes and lifecycles, and would make every module rebuild whenever either moved.
|
||||
|
||||
## Deferred
|
||||
|
||||
Contribution ordering and priority, a consumer-declared "extension point" manifest declaration,
|
||||
per-contribution permissions, contributions to *backend* extension points, host-mediated
|
||||
communication back from a contribution to its own module, contribution-level configuration, hot
|
||||
reload, and a module store or installer that would let contributions arrive without a restart.
|
||||
@@ -49,3 +49,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
|
||||
- [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted
|
||||
- [0040: Module stylesheets, and every browser artifact gets an isolated root](0040-module-stylesheets-and-artifact-isolation.md) — Accepted
|
||||
- [0041: One module may contribute a browser renderer to another module's capability](0041-module-browser-contributions.md) — Accepted
|
||||
|
||||
@@ -23,8 +23,8 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
||||
`@ekklesia/module-ui-sdk`. Since
|
||||
[ADR 0040](decisions/0040-module-stylesheets-and-artifact-isolation.md) it may also contain plain
|
||||
`.css`, but **only** inside a browser-artifact source root the canonical builder compiles
|
||||
(`src/ui/`, `src/display/`); a stylesheet anywhere else in a module is a file no build reads and no
|
||||
host loads. It still cannot contain `.jsx`, other stylesheet dialects, single-file components,
|
||||
(`src/ui/`, `src/display/`, `src/contributions/`); a stylesheet anywhere else in a module is a file
|
||||
no build reads and no host loads. It still cannot contain `.jsx`, other stylesheet dialects, single-file components,
|
||||
Astro/Marko/MDX/HTML views, or SVG presentation assets, and cannot depend on any other UI framework
|
||||
(including `react-router-dom`, `react-native`, and `@vitejs/plugin-react`) or on any shared role
|
||||
beyond the two approved design packages named below. A module needs no bundler plugin: `@ekklesia/module-ui-build` — a `tooling` package,
|
||||
@@ -63,7 +63,19 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
||||
module code remains trusted, same-realm code. The browser mount ABI is versioned
|
||||
(`MODULE_UI_ABI_VERSION`) and a mismatched artifact fails closed rather than mounting under guessed
|
||||
semantics.
|
||||
- Manifest v6 permits independently optional primary (`entrypoints.ui`) and full-viewport display
|
||||
- A module may declare **browser contributions** (`manifest.contributions`, v7): compiled renderers
|
||||
it offers to capabilities *other* modules provide
|
||||
([ADR 0041](decisions/0041-module-browser-contributions.md)). Core validates identity,
|
||||
compatibility, and the artifact path through the identical resolution every other compiled
|
||||
entrypoint uses, and knows nothing about what a contribution renders — there is no payload schema,
|
||||
slot, ordering, or rendering hint anywhere in the platform. A contribution is published only when
|
||||
another eligible module provides its target capability within the declared range; otherwise it is
|
||||
absent rather than broken. A consuming module discovers contributions through
|
||||
`ModuleUiHostContext.contributions` and mounts them by id; it never constructs a publication URL,
|
||||
and it can only ever see contributions aimed at a capability it itself provides. A contributed
|
||||
renderer receives its own id and the consumer's opaque JSON input, and no database, storage,
|
||||
credential, session, or API client of its own.
|
||||
- Manifest v7 permits independently optional primary (`entrypoints.ui`) and full-viewport display
|
||||
(`entrypoints.display`) artifacts. Modules own both feature experiences; surfaces own their
|
||||
containers, routes, navigation/chrome, credentials, error states, and publication wiring. The
|
||||
fixed authoring sources are `src/ui/index.tsx` and `src/display/index.tsx`; both use the same
|
||||
|
||||
@@ -73,7 +73,7 @@ without executing module code:
|
||||
|
||||
```json
|
||||
{
|
||||
"manifestVersion": 6,
|
||||
"manifestVersion": 7,
|
||||
"id": "calendar",
|
||||
"name": "Calendar",
|
||||
"version": "0.1.0",
|
||||
@@ -122,6 +122,16 @@ without executing module code:
|
||||
}
|
||||
]
|
||||
},
|
||||
"contributions": [
|
||||
{
|
||||
"id": "calendar.signage-slide",
|
||||
"version": "1.0.0",
|
||||
"capability": "signage.slide-renderer",
|
||||
"capabilityRange": "^1.0.0",
|
||||
"artifact": "./dist/contributions/signage-slide/index.js",
|
||||
"description": "Renders an upcoming-events slide for a signage timeline."
|
||||
}
|
||||
],
|
||||
"entrypoints": {
|
||||
"server": "./dist/server/index.js",
|
||||
"schema": "./dist/schema/index.js",
|
||||
@@ -140,12 +150,13 @@ has its own exact semantic version because its public contract can evolve indepe
|
||||
module release. Capability dependencies use semantic-version ranges. Optional means the provider
|
||||
may be absent; if it is installed, its capability version must be compatible.
|
||||
|
||||
`manifestVersion: 6` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
|
||||
version 5 manifest, and any other version, fails validation outright rather than being silently
|
||||
`manifestVersion: 7` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
|
||||
version 6 manifest, and any other version, fails validation outright rather than being silently
|
||||
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
|
||||
Version 6 adds the optional `entrypoints.display` object. A version 5 module that declares no display
|
||||
changes only the version literal; `ui` remains independently optional
|
||||
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
|
||||
Version 7 adds the optional `contributions` array
|
||||
([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)). A version 6 module that
|
||||
contributes nothing changes only the version literal; every browser artifact remains independently
|
||||
optional ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
|
||||
Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin
|
||||
with `<module-id>.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or
|
||||
`restricted` — see [Authorization](../development/authorization.md#permission-registry) for what
|
||||
@@ -214,6 +225,16 @@ are separate artifacts because a full-viewport display is not a Dashboard screen
|
||||
|
||||
```json
|
||||
{
|
||||
"contributions": [
|
||||
{
|
||||
"id": "calendar.signage-slide",
|
||||
"version": "1.0.0",
|
||||
"capability": "signage.slide-renderer",
|
||||
"capabilityRange": "^1.0.0",
|
||||
"artifact": "./dist/contributions/signage-slide/index.js",
|
||||
"description": "Renders an upcoming-events slide for a signage timeline."
|
||||
}
|
||||
],
|
||||
"entrypoints": {
|
||||
"server": "./dist/server/index.js",
|
||||
"schema": "./dist/schema/index.js",
|
||||
@@ -271,7 +292,7 @@ artifact is the role selection.
|
||||
|
||||
### What the host gives you
|
||||
|
||||
`mount` receives exactly five members, and nothing else is available:
|
||||
`mount` receives exactly six members, and nothing else is available:
|
||||
|
||||
| Member | What it is |
|
||||
| --- | --- |
|
||||
@@ -280,6 +301,7 @@ artifact is the role selection.
|
||||
| `principal` | Who is viewing: `ModuleUiPrincipal`, either `{ kind: "anonymous" }` or `{ kind: "authenticated", userId }` |
|
||||
| `permissions` | `has(permissionId)` — a presentation hint, never a security check |
|
||||
| `realtime` | `subscribe(channel, handlers)` against **your own** module's live state — see [Realtime state](#realtime-state) |
|
||||
| `contributions` | Renderers other modules contributed to capabilities **you** provide — see [Browser contributions](#browser-contributions) |
|
||||
|
||||
Navigation, module configuration, organization context, and diagnostics reporting are **not** in the
|
||||
contract. They need designs that do not exist, and a speculative field frozen into a public contract
|
||||
@@ -770,6 +792,132 @@ A Dashboard may later group modules into folders (`Media` holding Video and Live
|
||||
configuration an administrator can reorganize freely. They are not dependencies, they are not
|
||||
declared in your manifest, and your module must never require one to work correctly.
|
||||
|
||||
## Browser contributions
|
||||
|
||||
Sometimes another module owns a screen, and *you* own what belongs on part of it. A signage module
|
||||
owns a timeline; an announcements module knows what an announcement should look like. Neither can
|
||||
import the other — modules are independently installed, independently versioned peers — and neither
|
||||
should re-implement the other's job.
|
||||
|
||||
A **browser contribution** is the mechanism for exactly that
|
||||
([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)): a compiled renderer you
|
||||
declare for a capability *another* module provides, which that module mounts inside a container it
|
||||
owns.
|
||||
|
||||
```text
|
||||
signage provides the capability signage.slide-renderer, and publishes its input contract
|
||||
announcements contributes a React renderer for it
|
||||
signage mounts that renderer in one timeline slot, updates it, and takes it down
|
||||
```
|
||||
|
||||
### Contributing a renderer
|
||||
|
||||
Declare it in your manifest and put the source at the fixed path your contribution id implies:
|
||||
|
||||
```json
|
||||
{
|
||||
"contributions": [
|
||||
{
|
||||
"id": "announcements.signage-slide",
|
||||
"version": "1.0.0",
|
||||
"capability": "signage.slide-renderer",
|
||||
"capabilityRange": "^1.0.0",
|
||||
"artifact": "./dist/contributions/signage-slide/index.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```text
|
||||
src/contributions/signage-slide/index.tsx → the contribution's compiled artifact
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { defineModuleContribution } from "@ekklesia/module-ui-sdk";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import styles from "./styles.css";
|
||||
import { Slide, type SlideInput } from "./Slide.js";
|
||||
|
||||
export default defineModuleContribution({
|
||||
id: "announcements.signage-slide",
|
||||
version: "1.0.0",
|
||||
styles,
|
||||
mount(container, context) {
|
||||
const root = createRoot(container);
|
||||
root.render(<Slide input={context.input as SlideInput} />);
|
||||
return {
|
||||
update(input) {
|
||||
root.render(<Slide input={input as SlideInput} />);
|
||||
},
|
||||
unmount() {
|
||||
root.unmount();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The id is `<your-module-id>.<local-name>`, which is what makes it unique platform-wide without a
|
||||
registry. Your contribution carries **its own version**, independent of your module's, and the
|
||||
platform verifies the artifact claims it before mounting — a stale bundle is caught rather than
|
||||
rendered. `capabilityRange` says which provider versions you work with: if no installed module
|
||||
provides that capability inside your range, your contribution is simply not published. It is never
|
||||
mounted and broken.
|
||||
|
||||
`pnpm build` compiles it alongside your other artifacts. You configure no paths and no bundler.
|
||||
|
||||
### What a contributed renderer receives
|
||||
|
||||
Two things: its own id, and the consumer's `input`.
|
||||
|
||||
That is the whole context. **No API client, no realtime subscription, no principal, no permissions,
|
||||
no configuration, no database, no storage, and no credential.** Mounting your renderer grants your
|
||||
module nothing — server-side authorization is untouched, and everything the renderer needs arrives
|
||||
through the consumer's input, because the consumer is the party that was authorized to fetch it.
|
||||
|
||||
The input is **opaque JSON to the platform**. Ekklesia validates that it is genuinely
|
||||
JSON-serializable and nothing else: not its shape, not its meaning, not its version. The consuming
|
||||
module publishes that contract alongside its capability and versions it through its capability
|
||||
version. Validate what you receive and render a sensible default when it is not what you expected —
|
||||
you may be running against an older or newer consumer than the one you developed against.
|
||||
|
||||
`update` is what lets a long-running screen change content without restarting animations or reloading
|
||||
media on every tick. Implement it if you can render new input in place; omit it if you genuinely
|
||||
cannot, and the consumer will unmount and mount you again.
|
||||
|
||||
Your renderer mounts in its own isolated root with its own stylesheet, exactly like a module UI
|
||||
artifact (ADR 0040): your rules cannot reach the consuming module's screen around you, and its rules
|
||||
cannot reach in.
|
||||
|
||||
### Consuming contributions
|
||||
|
||||
If *your* module provides a capability other modules can render for, ask the host what was
|
||||
contributed:
|
||||
|
||||
```tsx
|
||||
const renderers = context.contributions.list("signage.slide-renderer");
|
||||
|
||||
const slide = await context.contributions.mount({
|
||||
container: slotElement, // an element your module owns
|
||||
contributionId: renderers[0].id,
|
||||
input: { headline, seconds }, // your own published contract
|
||||
});
|
||||
|
||||
await slide.update({ headline: next, seconds });
|
||||
await slide.unmount();
|
||||
```
|
||||
|
||||
You see identity, version, and capability — never a URL. Asking the host to mount by id is what keeps
|
||||
your module hostable somewhere other than Ekklesia, exactly as `context.api.request("records")` does.
|
||||
|
||||
You will only ever see contributions aimed at a capability **your** module provides, and only ones
|
||||
the server judged compatible. Handle an empty list: a contributor may be absent, disabled, or outside
|
||||
your compatible range, and that is ordinary rather than exceptional.
|
||||
|
||||
You own the input contract completely. Publish it beside your capability, version it with your
|
||||
capability version, and remember that a renderer written against an older version of it may still be
|
||||
installed.
|
||||
|
||||
## Runtime server definition
|
||||
|
||||
The source entrypoint exports one typed runtime definition and does not repeat the static manifest:
|
||||
|
||||
Reference in New Issue
Block a user