@wcstack/broadcast
v1.23.0
Published
Declarative cross-tab messaging component for Web Components. Framework-agnostic BroadcastChannel primitive via wc-bindable-protocol.
Maintainers
Readme
@wcstack/broadcast
🤖 AI coding agents: This README is a package-level reference, not the primary entry point for building a wcstack application. If you have not already done so, first read the repository README and AGENTS.md, then use the wcstack-app skill.
@wcstack/broadcast is a headless cross-tab messaging component for the wcstack ecosystem.
It is not a visual UI widget.
It is an async primitive node that turns same-origin cross-context messaging into reactive state — the same way @wcstack/fetch turns a network request into reactive state and @wcstack/websocket turns a socket into reactive state.
<wcs-broadcast> is the showcase for the wc-bindable token protocol crossing a context boundary. A BroadcastChannel delivers every post to every other same-origin context (tab, iframe, worker) on the same channel name — but never to the sender itself. So the two directions of the token protocol only close the loop across tabs:
- post (
state → element) via the command-token protocol —command.post: $command.send - message (
element → state) via the event-token protocol —eventToken.message: onMessage
With @wcstack/state, <wcs-broadcast> can be bound directly through path contracts:
- input surface:
name,manual - command surface:
open,post,close - output state surface:
message,error,errorInfo
This means cross-tab synchronization can be expressed declaratively in HTML, without writing new BroadcastChannel(), postMessage(), onmessage listeners, or teardown glue in your UI layer.
@wcstack/broadcast follows the CSBC (Core / Shell / Binding Contract) architecture:
- Core (
BroadcastCore) handles channel lifecycle, posting, structured-clone receipt, and error handling - Shell (
<wcs-broadcast>) connects that state to DOM attributes, lifecycle, and declarative commands - Binding Contract (
static wcBindable) declares observableproperties, writableinputs, and callablecommands
Why this exists
The BroadcastChannel API is, like fetch or WebSocket, an asynchronous source of values — but it is self-excluding: a context never receives its own posts. Imperatively it requires constructing the channel, wiring message / messageerror listeners, and closing on teardown.
@wcstack/broadcast moves that logic into a reusable component and exposes the result as bindable state. A cross-tab notification becomes a state transition, not imperative callback wiring.
Self-exclusion — open the page in two tabs. Because a context never hears its own posts, a single
<wcs-broadcast>in one tab will not see its ownpostreflected back intomessage. The round trip only closes when another context (another tab, or another<wcs-broadcast>on the same channel name) is listening. Demos in this README assume the page is open in two tabs.
Same-origin only, structured clone. BroadcastChannel works within one origin. Payloads ride the browser's structured clone, so objects pass through as-is — there is no JSON round-trip and no need to stringify. A non-cloneable payload (a function, a DOM node) surfaces a
DataCloneErrorthrough theerrorproperty rather than throwing.
Install
npm install @wcstack/broadcastQuick Start
1. Send a message (post)
Drive a post from a DOM click (autoTrigger) or a command-token.
<script type="module" src="https://esm.run/@wcstack/state/auto"></script>
<script type="module" src="https://esm.run/@wcstack/broadcast/auto"></script>
<wcs-broadcast id="bc" name="room"></wcs-broadcast>
<!-- Optional DOM triggering: click posts the literal text -->
<input id="msg" value="hello" />
<button data-broadcast-target="bc" data-broadcast-from="#msg">Send</button>
<button data-broadcast-target="bc" data-broadcast-text="ping">Ping</button>data-broadcast-text posts a literal string; data-broadcast-from posts the value (or textContent) of the element matched by the selector.
2. Cross-tab counter (command-token + event-token)
The duality in one element: post is wired from a command-token, and an incoming message is received via an event-token. Open this in two tabs and click "Bump" — each tab's count stays in sync.
<wcs-state>
<script type="module">
export default {
count: 0,
$commandTokens: ["send"],
$eventTokens: ["onMessage"],
bump() {
this.count = this.count + 1;
this.$command.send.emit(this.count); // state → element → other tabs
},
$on: {
onMessage: (state, event) => { // other tabs → element → state
state.count = event.detail;
}
}
};
</script>
</wcs-state>
<wcs-broadcast name="counter" data-wcs="
command.post: $command.send;
eventToken.message: onMessage
"></wcs-broadcast>
<button data-wcs="onclick: bump">Bump</button>
<p data-wcs="textContent: count"></p>3. Mirror a received value into state
You do not need the event-token to read the latest message — bind message directly.
<wcs-state>
<script type="module">
export default { incoming: null };
</script>
</wcs-state>
<wcs-broadcast name="room" data-wcs="message: incoming"></wcs-broadcast>
<p data-wcs="textContent: incoming"></p>Attributes / Inputs
| Attribute | Type | Default | Description |
| --------- | ------- | ------- | ---------------------------------------------------------------------------- |
| name | string | "" | The channel name to join. Changing it re-opens on the new channel. |
| manual | boolean | false | Do not open the channel automatically on connect or on name change. Call open() instead. Evaluated at connect time and on each name change; it is not in observedAttributes, so toggling manual on an already-connected element has no immediate effect (it only changes how the next connect or name change behaves). |
DOM trigger attributes (autoTrigger, post-on-click)
| Attribute | On | Description |
| ----------------------- | -------------- | ----------------------------------------------------------------------- |
| data-broadcast-target | trigger button | Id of the <wcs-broadcast> to drive. |
| data-broadcast-text | trigger button | Literal text to post (takes precedence; empty string is valid). |
| data-broadcast-from | trigger button | CSS selector; posts the matched element's value (or textContent). |
A DOM-triggered
postis fire-and-forget; it never rejects. A failed post (e.g. a non-cloneable payload — not possible from a DOM trigger, which only posts strings) surfaces through theerrorproperty.
Observable Properties (outputs)
| Property | Event | Description |
| --------- | ------------------------ | ------------------------------------------------------------------------------------ |
| message | wcs-broadcast:message | The last value received from another context on the channel (structured-clone copy). Never set by this context's own posts. |
| error | wcs-broadcast:error | Normalized { name, message } — DataCloneError (non-cloneable post), DataError (a peer's message could not be deserialized), InvalidStateError (post with no open channel), or NotSupportedError (BroadcastChannel unavailable). |
| errorInfo | wcs-broadcast:error-info-changed | Serializable failure taxonomy WcsIoErrorInfo \| null (stable code / phase / recoverable), derived from the same failure as error. Additive — the error shape is unchanged. |
Commands
| Command | Description |
| ------- | --------------------------------------------------------------------------------------- |
| open | Join the channel named by the name attribute (closes any previously-open channel). |
| post | Post a structured-cloneable value to every other context (never rejects — failures go to error). |
| close | Leave the channel (idempotent). |
State-driven invocation uses the command-token protocol:
<wcs-broadcast name="room" data-wcs="command.post: $command.send"></wcs-broadcast>CSS styling with :state()
<wcs-broadcast> reflects one boolean output state onto its
ElementInternals CustomStateSet,
so you can style it directly from CSS with the :state() pseudo-class — no
data-wcs binding or extra class toggling required. <wcs-broadcast> has no
loading/connected-style boolean output (posting and opening a channel are
synchronous), so error is the only reflected state.
| State | On when |
|-------|---------|
| error | wcs-broadcast:error fires with a non-null detail (cleared on null) |
form:has(wcs-broadcast:state(error)) .banner { display: block; }Unlike attributes or classes, :state() cannot be written from outside the
element, so there is no risk of confusing this output state with an input.
Browser support (:state(x) syntax): Chrome/Edge 125+, Safari 17.4+,
Firefox 126+. In older browsers the states are simply never set — :state()
selectors never match, but <wcs-broadcast> itself keeps working normally
(graceful degradation, never-throw).
SSR: :state() cannot be serialized into HTML, so server-rendered markup
never carries these states on first paint (@wcstack/server is unaffected).
If you need to style the pre-hydration gap, pair your rule with
wcs-broadcast:not(:defined) instead.
Debugging
Custom states are invisible in DevTools' Elements panel and attachInternals()
cannot be called twice, so there is no console way to inspect them directly.
Two debug-only aids are provided for that:
el.debugStates— a snapshot array of the currently-on state names (e.g.["error"]). It is not part ofwc-bindable(not a bind target) and its shape is not a guaranteed contract — use it for debugging only.The
debug-statesattribute (opt-in, default off) mirrors state changes onto adata-wcs-state-errorattribute on the element, so the Elements panel highlights it as it toggles:<wcs-broadcast name="room" debug-states></wcs-broadcast>
Write your CSS against :state(), not data-wcs-state-*. The mirrored
attribute exists purely to make state changes visible while debugging with
DevTools open; it is not a supported styling hook.
Notes & limitations
- Self-exclusion is intentional. A context never receives its own posts — this is the BroadcastChannel contract, not a bug. To see a round trip, have a second context (tab/iframe/worker, or a second
<wcs-broadcast>on the same channel name) listening. Two<wcs-broadcast name="x">elements in the same tab do hear each other (they are distinct channel objects); only a single element talking to itself does not. nameis observed. Unlike<wcs-clipboard>,<wcs-broadcast>implementsobservedAttributesforname: changing thenameattribute while connected (and notmanual) closes the old channel and opens the new one. Clearing thename(setting it to an empty string or removing the attribute) is not a close: the previously-open channel is kept until you switch to anothernameor callclose()explicitly. Only a non-empty new value triggers the switch.- No wire encoding. Payloads use structured clone, so there is no JSON stringify/parse step (unlike
<wcs-ws>, which sends over a text wire). Post objects directly; receivers get a deep copy. Non-cloneable values fail withDataCloneErrorviaerror. - No connection state. A BroadcastChannel is "open" the moment it is constructed — there is no connecting/handshake phase, no
readyState, and no reconnect (none is needed). The Shell opens synchronously on connect, so itsconnectedCallbackPromiseresolves immediately (an already-resolved promise). It is still exposed (hasConnectedCallbackPromise = true) so a state binder / SSR can uniformly await readiness before snapshotting. - Reconnect re-opens. Removing and re-inserting the element runs
connectedCallbackagain, re-opening the channel from thenameattribute (the source of truth), anddisconnectedCallbackcloses it. - Silent failure handling (zero-log). Consistent with the rest of wcstack's zero-dependency philosophy,
<wcs-broadcast>never logs or throws for runtime failures. A missing BroadcastChannel constructor, a non-cloneable post, or a deserialization failure are surfaced only through theerrorproperty /wcs-broadcast:errorevent —post()resolves and never rejects. Binderrorto observe and react. errorInfotaxonomy. An additive bindable output (wcs-broadcast:error-info-changed) that classifies the same failure surfaced onerrorinto a serializableWcsIoErrorInfowith a stablecode/phase/recoverable, without changing theerrorshape. A missingBroadcastChannelconstructor (NotSupportedError) iscapability-missing(phaseprobe); a non-cloneable post (DataCloneError— caller input) isinvalid-argument(phaseexecute); anything else (aDataErrordeserialization failure, anInvalidStateError, or anErrorfallback) isbroadcast-error(phaseexecute). All arerecoverable: false(the same input re-sent will not recover).errorInfostays in lockstep witherror(same transitions, cleared alongside it). The sharedWcsIoErrorInfotype and theWCS_BROADCAST_ERROR_CODEconstants are exported.
Headless usage (BroadcastCore)
The Core has no DOM dependency beyond the global BroadcastChannel and can be used directly with bind() from @wc-bindable/core:
import { BroadcastCore } from "@wcstack/broadcast";
const bus = new BroadcastCore();
bus.addEventListener("wcs-broadcast:message", (e) => {
console.log((e as CustomEvent).detail); // the received value
});
bus.open("room");
bus.post({ type: "hello", at: Date.now() });
// ...later
bus.close();The structural Core surface is normative across wcstack IO nodes (async-io-node-guidelines §3.9); to bind it into signals with no element at all, see @wcstack/signals — Binding a Core directly.
License
MIT
