@wc-bindable/composite
v0.8.0
Published
Composition profile for wc-bindable — expose many wc-bindable targets as one (Extension 4)
Readme
@wc-bindable/composite
Reference implementation of the wc-bindable Composition profile (SPEC-extensions.md § Extension 4 / COMPOSITE.md).
Expose many wc-bindable source targets as one wc-bindable shell target.
The composed shell is an ordinary wc-bindable target, so every existing consumer
— bind(), the framework adapters, and @wc-bindable/remote — works against it
unchanged.
@wc-bindable/core = observe one target
@wc-bindable/remote = proxy one target across a transport
@wc-bindable/composite = expose many targets as one targetProfile tiers
This package implements:
- T1 — Observation (always): discovery, a synthesized
static wcBindabledeclaration, name mapping, the three-phase event fan-out, getter / undefined preservation, initial-syncinsemantics, and lifecycle teardown. - T2 — Local facade (default when inputs/commands are exposed): composed
inputs become assignable members (
shell["ai.prompt"] = v) and composed commands become callable members (shell["ai.run"](...)), delegating to the mapped source.
Tier T3 (the Extension-1 set / setWithAck / invoke consumer surface)
is not implemented here; the per-instance tier claim reports
extension1: false.
Do you need T3?
T3 is the consumer-facing Extension 1 method surface — shell.set(name, v),
shell.setWithAck(...), shell.invoke(name, ...), plus their *WithOptions
variants and dispose(). You only need it when application code (or a downstream
adapter) calls those methods by name and relies on acknowledged delivery,
AbortSignal / timeoutMs, or the WC_BINDABLE_* error envelope.
You very likely do not need T3 if you only want to:
| Goal | Use |
|---|---|
| Read composed properties | T1 (default) |
| Write inputs / call commands locally | T2 facade: shell["ai.prompt"] = v, shell["ai.run"](...) |
| Expose writable inputs / invokable commands over @wc-bindable/remote | T2 — RemoteShellProxy drives the producer through the local facade, not through T3 methods |
So set / setWithAck / invoke as methods are the only thing missing.
Until a future version of this package (or a separate package) adds T3, the
options are:
- Prefer T2 for local writes and for remote-writable producers — it covers the large majority of "I want to set/invoke" cases.
- If you specifically need the Extension-1 method surface on the composed shell, wrap this shell in your own thin Extension-1 layer that resolves the composed name and delegates to the source (the routing rules are specified in COMPOSITE.md § 8 / § 9). Do not advertise it as "T3" unless it implements the full surface (see COMPOSITE.md § Profile tiers).
A T3 implementation will set extension1: true on the tier claim; tools should
gate the set/invoke affordance on that flag, not assume it.
JavaScript API
import { createCompositeTarget } from "@wc-bindable/composite";
import { bind } from "@wc-bindable/core";
const shell = createCompositeTarget({
sources: { s3: s3Uploader, ai: aiAgent },
expose: {
properties: {
"s3.progress": { source: "s3", name: "progress" },
"ai.answer": { source: "ai", name: "answer" },
},
inputs: { "ai.prompt": { source: "ai", name: "prompt" } },
commands: { "ai.run": { source: "ai", name: "run" } },
},
});
// Observe like any wc-bindable target.
const unbind = bind(shell, (name, value) => console.log(name, value));
// T2 local facade.
shell["ai.prompt"] = "summarize this";
await shell["ai.run"]();
shell.dispose(); // detaches every source listener (does NOT dispose sources)Omit expose (or pass "all-prefixed") to auto-expose every source member
under its default <sourceId>.<sourceName> name:
const shell = createCompositeTarget({
sources: { s3: s3Uploader, ai: aiAgent },
expose: "all-prefixed",
});Production tip — pin the public surface with an explicit
exposemap."all-prefixed"derives the shell's public API from whatever the sources currently declare, so adding a source, or a source changing its ownwcBindabledeclaration, silently changes the composed shell's surface — potentially adding/removing properties, inputs, or commands that downstream consumers depend on. It is great for prototyping; for a stable contract, list the members you intend to expose explicitly (an unknown member name in the map then fails fast at construction)."all-prefixed"is a documented convenience, not a normative requirement (see COMPOSITE.md § Open questions).
Declarative custom-element API (Declarative Shadow DOM)
For HTML-first systems (e.g. @wcstack/state), declare a composite as a custom
element. A definition element carries data-wc-composite-definition; its tag
name becomes the composed custom element, and its Declarative Shadow DOM
(<template shadowrootmode="open">, legacy shadowroot="open" also accepted)
wraps the composed source elements. Each source is tagged with
data-wc-source="<id>" and may carry an optional data-wc-expose.
<my-ai-workbench data-wc-composite-definition>
<template shadowrootmode="open">
<s3-uploader data-wc-source="s3"
data-wc-expose="properties: progress, url; inputs: file; commands: upload"></s3-uploader>
<ai-agent data-wc-source="ai"
data-wc-expose="properties: loading, answer; inputs: prompt; commands: run"></ai-agent>
</template>
</my-ai-workbench>
<!-- After registration, plain instances compose their sources: -->
<my-ai-workbench></my-ai-workbench>import { registerCompositeDefinitions } from "@wc-bindable/composite";
// Scans for [data-wc-composite-definition], awaits each source tag's
// customElements.whenDefined(), then registers each composed custom element.
await registerCompositeDefinitions(document);The first argument is the scan root and defaults to document; pass any
ParentNode (or a definition element itself) to scope the scan — important in
large apps or when multiple frameworks share a page:
// Only register definitions inside #app (the root element is itself included
// if it carries data-wc-composite-definition).
await registerCompositeDefinitions(document.querySelector("#app")!);Each instance builds its own shadow root containing one fresh instance of
every source tag, binds an engine to them, and exposes the composed surface
(property getters, and the T2 input setters / command members). Omitting
data-wc-expose auto-exposes every source member (subject to the same
"pin the surface explicitly in production" caveat as the "all-prefixed" JS
default above).
data-wc-expose syntax
data-wc-expose is a package-specific convenience attribute (not part of the
normative profile; COMPOSITE.md shows it under the non-normative declarative
candidate API). Per-source grammar, whitespace-insensitive:
properties: <name>, <name>; inputs: <name>; commands: <name>, <name>- Segments are separated by
;; each segment is<kind>: <comma-separated names>. <kind>isproperties/inputs/commands(singularproperty/input/commandalso accepted). Segments may appear in any order, and any kind may be omitted.- Names are the source-local member names; the composed public name becomes
<sourceId>.<name>.
Errors fail loudly at registration — a segment with no : ("progress"),
an unknown kind ("propertis: x"), a name that does not exist on the source, a
duplicate composed name, or a reserved name all throw (the
registerCompositeDefinitions / defineComposite promise rejects). There is no
silent "best-effort" partial parse. Build the expose map in JS instead (pass
expose to defineComposite) if you prefer not to use the string DSL.
You can also register programmatically:
import { defineComposite } from "@wc-bindable/composite";
const Workbench = await defineComposite({
tagName: "my-ai-workbench",
sources: [
{ id: "s3", tag: "s3-uploader" },
{ id: "ai", tag: "ai-agent" },
],
// expose?: ExposeConfig — defaults to "all-prefixed"
});Source-definition precondition. Per § Declarative custom element API, the composed element is registered only after every source tag is defined (
customElements.whenDefined), so a shell instance is never observable with a provisional declaration.
Class authoring
defineComposite returns an opaque, already-registered class. When you want to
add your own behavior (methods, extra rendering, lifecycle work) on top of
the composed surface, use defineCompositeClass: it returns an unregistered
base class you subclass and register yourself.
import { defineCompositeClass } from "@wc-bindable/composite";
const Base = await defineCompositeClass({
sources: [
{ id: "s3", tag: "s3-uploader" },
{ id: "ai", tag: "ai-agent" },
],
// expose?: ExposeConfig — defaults to "all-prefixed"
});
class MyAiWorkbench extends Base {
reset() {
this["ai.prompt"] = ""; // your own method on top of the composed T2 facade
}
}
customElements.define("my-ai-workbench", MyAiWorkbench);The synthesized static wcBindable lives on the base and is statically inherited
by your subclass, so target.constructor.wcBindable discovery, bind(), the
framework adapters, and @wc-bindable/remote all work against instances
unchanged. Like defineComposite, this awaits every source tag's
customElements.whenDefined() first, so the declaration is fully determined
before the class is returned.
The base owns its shadow root for the source instances and sets them up in
connectedCallback. A subclass that overrides connectedCallback MUST call
super.connectedCallback(). The base only manages the source elements it
creates ([data-wc-source]) — it never wipes other shadow content, so a subclass
may render its own shadow DOM alongside the sources.
Lit
@wc-bindable/composite/lit builds on the same machinery for
Lit. CompositeLitElement(options) returns a LitElement
base that is a composite shell and re-renders your render() whenever a
composed value changes (lit is an optional peer dependency):
import { html } from "lit";
import { customElement } from "lit/decorators.js";
import { CompositeLitElement } from "@wc-bindable/composite/lit";
const Base = await CompositeLitElement({
sources: [
{ id: "s3", tag: "s3-uploader" },
{ id: "ai", tag: "ai-agent" },
],
});
@customElement("my-ai-workbench")
class MyAiWorkbench extends Base {
render() {
return html`
<progress .value=${(this["s3.progress"] as number) ?? 0}></progress>
<p>${this["ai.answer"]}</p>
<button @click=${() => { this["ai.prompt"] = "summarize"; (this["ai.run"] as () => unknown)(); }}>
Run
</button>
`;
}
}Composed names are dotted strings, not Lit reactive properties, so the base
bridges each composed update to requestUpdate() for you. The source elements
are created once in the element's shadow root; Lit renders your template into the
same shadow root (after the sources), so static styles are adopted as
usual and the two never clash. Call el.dispose() for terminal teardown.
Lit ReactiveController
When you'd rather render the sources in your own template and control their
placement, use CompositeController instead of the base class. It composes the
instances you render (captured via the Lit ref directive it hands you),
exposes the composed values on .values (re-rendering the host on each change),
and exposes the composed shell on .shell:
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { CompositeController } from "@wc-bindable/composite/lit";
@customElement("my-ai-workbench")
class MyAiWorkbench extends LitElement {
#c = new CompositeController(this, {
sources: [
{ id: "s3", tag: "s3-uploader" },
{ id: "ai", tag: "ai-agent" },
],
});
render() {
return html`
<s3-uploader ${this.#c.ref("s3")}></s3-uploader>
<ai-agent ${this.#c.ref("ai")}></ai-agent>
<p>${(this.#c.values as Record<string, unknown>)["ai.answer"]}</p>
<button @click=${() => { this.#c.shell!["ai.prompt"] = "hi"; this.#c.shell!["ai.run"](); }}>
Run
</button>
`;
}
}The difference from CompositeLitElement:
| | CompositeLitElement (base class) | CompositeController |
|---|---|---|
| Sources | created for you (hidden in shadow) | you render them, tagged with ref(id) |
| What is the shell | the host element itself | a separate handle on .shell |
| Composed values | property getters on the element (this["ai.answer"]) | .values snapshot |
| Shell identity | stable for the element's lifetime | rebuilt on reconnect (controller lifecycle) |
.shell is a real wc-bindable target (built via createCompositeTarget), so you
can also hand it to @wc-bindable/remote or bind() it elsewhere. Because the
controller follows the usual connect/disconnect lifecycle, .shell is torn down
on disconnect and rebuilt on reconnect — reach for CompositeLitElement or
createCompositeTarget directly if you need a shell whose identity is stable
across disconnects.
Tier claim discovery
Every shell exposes a frozen, per-instance tier claim under the well-known
symbol Symbol.for("wc-bindable.composite.tiers"):
import { COMPOSITE_TIERS_SYMBOL } from "@wc-bindable/composite";
shell[COMPOSITE_TIERS_SYMBOL];
// { protocol: "wc-bindable.composite", version: 1,
// localFacade: true, extension1: false }A tool MUST read this (not the core declaration) to decide whether to render set / invoke affordances — a declared input/command is metadata, not a guarantee of delegation. The claim is a hint, not a permission: composition is not a security boundary.
Notable invariants implemented
- Three-phase fan-out (extract → commit → dispatch) so siblings sharing one source event see a coherent committed snapshot, and a getter never observes a partially-updated cache (COMPOSITE.md § 6).
- Getter-failure isolation: a throwing source getter is reported (via
reportError, else the injected logger) and its siblings still dispatch (§ 12). - undefined / null preservation: values are carried out-of-band on the shell
event, not through
CustomEvent.detail(which would coerceundefined→null) (§ 7). - Reserved-name rejection mirroring the remote wire profile (the
@wc-bindable/prefix plus__proto__/constructor/prototype), the fixed-API members, and the T2 cross-surface collision rule (§ 4). - Immutable synthesized declaration (frozen for the shell's lifetime, § 11).
- Borrow semantics:
dispose()detaches the shell's own source listeners and never disposes the source targets (§ 10).
Remote interop
A composed shell can include RemoteCoreProxy sources, and a T2 shell may be
exposed through @wc-bindable/remote for writable inputs / invokable commands.
A non-T2 (observation-only) shell exposed remotely must use an observation-only
wire projection — see COMPOSITE.md § Remote interop. Set remoteCompatible: true
on the config to advertise the claim; reserved names are rejected regardless.
License
MIT
