component-interop

A zero-dependency, manifest-driven capability broker for web components. It lets independently-authored component libraries share a store, a session, and capabilities — and coordinate navigation — without importing each other and without page glue code.

Each library ships (or is described by) a small JSON manifest declaring what it provides and consumes. One <script> tag names everything the page draws from those libraries — components (data-components), shared objects (data-objects), and attributes (data-attributes) — and the broker pairs each opted-in consumer with a provider in another library. The result is plain custom elements that interoperate.

The opt-in invariant: nothing ci activates that the script tag doesn’t name. Load as many manifests as you like; until the page names a capability, it stays dormant — so the tag is the complete inventory of what the page uses and what cross-wires.

Library author? See Making your library shareable — the manifest entries and one-function recipes for sharing in every direction.

Install / use

<script src="component-interop.js"
        data-manifest="my-lib.manifest.json other-lib.manifest.json"
        data-components="my-widget"
        data-objects="store"></script>

It is a classic script (no build step needed), exposes window.ComponentInterop, and has no runtime dependencies. Load it as a plain blocking <script> in <head> — not async, defer, or type="module". It reads the manifests and brokers the libraries. (If a manifest also supplies an import map, ci injects it synchronously before the parser moves on — see Using the manifest for import maps — because a map added after any module load has started is rejected.)

What it does

A manifest’s offerings — what you read to use a library — are components (elements to place), attributes (a data-* you use), and objects (a value shared library→library):

  1. componentsimport()s the modules named in data-components (or "*" for all). Each resolves against its manifest’s URL if relative/absolute, or via an import map if a bare specifier.
  2. attributes — a manifest-declared data-* loads its module(s) when the page both names it in data-attributes and uses it in the DOM. Named-but-unused loads nothing; used-but-unnamed loads nothing.
  3. objects — pairs a library’s consumes/accepts with another library’s provides of the same key (the “adopt the other library’s value” rule), but only for keys the page opts into with data-objects="key …". No opt-in → no cross-wiring, even when both manifests declare the channel.
  4. a shared registryComponentInterop.services (register / whenReady / get / has) so libraries publish and discover shared objects without importing each other.

The manifest

Offerings (read these to use the library) — components, attributes, objects. (A manifest can ALSO carry an import map — shared-modules/stages — but that’s optional; see Using the manifest for import maps. A group of modules that loads as one unit is just a barrel module — a JS file that imports its constituents — named like any other module.)

{
  "@context": "https://jeff-zucker.github.io/component-interop/context.jsonld",
  "@id": "",                                          // ↑ JSON-LD header: ci ignores these three;
  "@type": "Manifest",                                //   they make the manifest a valid RDF document
  "name": "my-lib",                                   // library identity (required for sharing)
  "components": {
    "my-widget": "./my-widget.js",                    // placeable element → module (URL or bare specifier)
    "my-card": {                                      // …or an object: module + display metadata
      "module": "./my-card.js",                       //   (module optional when `stages` carry the URLs)
      "label": "Card",                                //   label for a tab / button / palette card
      "icon": "🃏",                                   //   emoji or icon URL
      "title": "Browse a card deck",                  //   hover text
      "description": "One-line description.",
      "params": [{ "name": "source", "value": "./cards.ttl" }],  // default attributes
      "shape": "./shapes/card.shacl",                 //   SHACL shape its data conforms to
      "data": ["./data/cards.ttl"],                   //   data document(s) it reads/writes
      "help": "./help/my-card.html"                   //   USER online help (not dev docs)
    }
  },
  "attributes": {                                     // a data-* → its module(s) (page opts in via data-attributes)
    "data-login": { "module": "./my-login.js" },
    "data-edit-shape data-subject": { "module": "./editing.js" }   // space-separated keys share modules
  },
  "objects": {
    "provides": {                                     // offer a value (from a service or an event)
      "store":      { "service": "store", "sendValue": "graph" },
      "navigation": { "respondTo": "my:navigate", "sendValue": "detail.url" }
    },
    "consumes": { "store":      { "call": "adoptStore" } },   // adopt by calling a registered handler
    "accepts":  { "navigation": { "onElement": "other-viewer", "applyValueTo": "src", "transform": "stripHash" } }  // adopt by setting an attr
  }
}

The dividing rule — exposing a value is data; adopting one is behavior. A provides entry is just the broker reading a value and handing it out, so it lives entirely in the manifest (no code at all if you already fire the event). A consumes entry needs the one registered function, because integrating a foreign value into your own runtime is your logic. So a library makes a resource offerable with a manifest add, and adoptable with one function.

Composing multiple libraries

The broker is N-library by design — list more manifests; it pairs every opted-in consumes (the keys named in data-objects) with a provider in any other library, and the resource channel + shared services registry are page-wide, so all of them share one store/session/current-resource with no per-pair glue:

<script src="component-interop.js"
        data-manifest="lib-a.manifest.json lib-b.manifest.json lib-c.manifest.json"
        data-components="lib-a lib-b lib-c"
        data-objects="rdf:lib-b navigation"></script>   <!-- rdf hosted by lib-b; navigation: no preference -->

Conventions that make N libraries coherent:

  1. Externalize shared deps. Common deps (rdflib, …) must resolve to one instance — share a single resolved URL (via one import map; see Using the manifest for import maps) rather than each library inlining its own copy. A library that bundles its own copy gets a second instance and breaks single-store coherence.
  2. Agree on capability names. Pairing is by capability nameconsumes.rdf pairs with another library’s provides.rdf. Across ecosystems either agree on names or ship a tiny descriptor manifest mapping a library’s terms to the shared ones (see examples/solpos/, which describes PodOS in ~14 lines).
  3. Choose among multiple providers when two libraries provide the same capability. The broker picks one in this order: the page’s preference (data-prefer, a JSON map capability → library, or the key:provider inline host in data-objects like store:pod-os — an explicit data-prefer wins if both name a key) → the consumer’s from → highest provider priority → earliest in manifest order. See examples/multi-provider/.
  4. Namespace global names. registerConsumer handler names are page-global — prefix them (libA.adoptStore) so two libraries don’t collide.
  5. Cross-origin libraries. data-manifest may point at a cross-origin manifest (it loads when that server sends CORS headers), and the modules a manifest names can be cross-origin too (a CDN). A small local descriptor manifest per library that maps its terms/modules is still handy for adapting a foreign library — the examples/solpos/ pattern — but it’s no longer required just to cross an origin.

data-* attributes (data-base, data-stage, …)

(There is no data-extend-with — an attribute loads when it is named in data-attributes and used on the page.)

Using the manifest for import maps (optional)

Everything above works whether or not ci builds an import map: if a manifest references its modules by relative/absolute URL, ci import()s them directly. A manifest can ALSO carry an import map, so libraries reference their deps by bare specifier instead of hard-coding URLs:

{
  "name": "my-lib",
  "shared-modules": { "rdflib": "./vendor/rdflib.js" },   // externalized deps → URL
  "components":     { "my-widget": "./my-widget.js" },    // component URLs also feed the map
  "stages": {                                             // optional per-env URL sets, chosen by data-stage
    "local": { "shared-modules": { "rdflib": "./vendor/rdflib.js" } },
    "cdn":   { "shared-modules": { "rdflib": "https://esm.sh/rdflib" } }
  }
}

ci merges every manifest’s entries first-wins into one <script type="importmap"> and injects it synchronously, before any module loads (a map added after a module load is rejected — Firefox strict, Chromium lenient). data-stage (local|cdn|autoauto = local on localhost/file:, else cdn) picks the stage; data-importmap-extra adds inline entries. If the page already owns an <script type="importmap">, ci yields to it and uses that instead.

Why you’d want it: bare specifiers stay location-flexible — swap dev↔CDN by editing only the map/stage, not every manifest — and the union of every library’s externalized deps is collected and deduped to one instance automatically (one rdflib), instead of each app hand-authoring and reconciling a map.

When you don’t need it: reference modules by relative/absolute URL in the manifest and ci imports them directly — no import map at all. (Bare runtime deps a library imports internally — its own import 'rdflib' — still need an import map, ci’s or a page-owned one, regardless; that part isn’t ci-specific.)

API (window.ComponentInterop)

ready (Promise) · load(components) · manifest (.components / .attributes / .meta — per-tag display metadata: label, icon, title, description, params, shape, data, help) · loaded · version · registerCapability(name, {modules, attributes}) · registerConsumer(name, fn) · services (register / get / has / names / whenReady) · has(name) · capabilities · on(name, fn) · emit(name, detail). Events: interop:ready, interop:capability (per capability), interop:wired (per provide→consume binding).

Security

A manifest names modules ci import()s, so point data-manifest only at manifests you trust — the page author chooses them, and a cross-origin one loads only if that server sends CORS headers. Consumer handlers are invoked from a library-provided registry, never eval‘d from a manifest string, so a manifest can name a handler but never supply code.

Examples

examples/ is a live, runnable demo — a PodOS app gaining Solid Web Components capabilities, wired entirely by the broker, no glue. Open examples/index.html (a tabbed shell): SPARQL on a plain element, a shared store, shared navigation, auto-generated forms, and auth — each a swc capability adopted by a PodOS page. It loads sol-components and PodOS from the CDN, so it runs from this repo alone.