@wcstack/broadcast 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +178 -0
- package/README.md +178 -0
- package/dist/auto.js +3 -0
- package/dist/auto.min.js +3 -0
- package/dist/index.d.ts +193 -0
- package/dist/index.esm.js +437 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.esm.min.js +2 -0
- package/dist/index.esm.min.js.map +1 -0
- package/package.json +72 -0
package/README.ja.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# @wcstack/broadcast
|
|
2
|
+
|
|
3
|
+
`@wcstack/broadcast` は wcstack エコシステム向けのヘッドレスなタブ間メッセージングコンポーネントです。
|
|
4
|
+
|
|
5
|
+
これは視覚的な UI ウィジェットではありません。
|
|
6
|
+
`@wcstack/fetch` がネットワークリクエストをリアクティブな状態に変え、`@wcstack/websocket` がソケットをリアクティブな状態に変えるのと同じように、**同一オリジンのコンテキスト間メッセージングをリアクティブな状態に変える非同期プリミティブノード**です。
|
|
7
|
+
|
|
8
|
+
`<wcs-broadcast>` は wc-bindable トークンプロトコルが**コンテキスト境界**をまたぐショーケースです。BroadcastChannel は、同じチャンネル名上の他のすべての同一オリジンコンテキスト(タブ・iframe・worker)にすべての post を配信しますが、送信者自身には決して配信しません。そのため、トークンプロトコルの両方向はタブを*またいで*はじめてループを閉じます。
|
|
9
|
+
|
|
10
|
+
- **post**(`state → element`)— command-token プロトコル経由(`command.post: $command.send`)
|
|
11
|
+
- **message**(`element → state`)— event-token プロトコル経由(`eventToken.message: onMessage`)
|
|
12
|
+
|
|
13
|
+
`@wcstack/state` と組み合わせると、`<wcs-broadcast>` はパス契約を通じて直接バインドできます。
|
|
14
|
+
|
|
15
|
+
- **入力面**: `name`, `manual`
|
|
16
|
+
- **コマンド面**: `open`, `post`, `close`
|
|
17
|
+
- **出力状態面**: `message`, `error`
|
|
18
|
+
|
|
19
|
+
つまり、タブ間の同期を HTML 上で宣言的に表現でき、UI 層に `new BroadcastChannel()` / `postMessage()` / `onmessage` リスナ、後始末のグルーコードを書く必要がありません。
|
|
20
|
+
|
|
21
|
+
`@wcstack/broadcast` は [CSBC](https://github.com/csbc-dev/arch/blob/main/README.md)(Core / Shell / Binding Contract)アーキテクチャに従います。
|
|
22
|
+
|
|
23
|
+
- **Core**(`BroadcastCore`)がチャンネルのライフサイクル、post、structured clone による受信、エラー処理を担当
|
|
24
|
+
- **Shell**(`<wcs-broadcast>`)がその状態を DOM 属性・ライフサイクル・宣言的コマンドに接続
|
|
25
|
+
- **Binding Contract**(`static wcBindable`)が観測可能な `properties`・書き込み可能な `inputs`・呼び出し可能な `commands` を宣言
|
|
26
|
+
|
|
27
|
+
## なぜ存在するのか
|
|
28
|
+
|
|
29
|
+
BroadcastChannel API は `fetch` や `WebSocket` と同様、値を非同期に生み出すソースですが、**自己除外的**です。すなわち、あるコンテキストは自分自身の post を決して受信しません。命令的に書くと、チャンネルの構築・`message` / `messageerror` リスナの配線・解体時のクローズが必要になります。
|
|
30
|
+
|
|
31
|
+
`@wcstack/broadcast` はそのロジックを再利用可能なコンポーネントに押し込み、結果をバインド可能な状態として公開します。タブ間の通知が命令的なコールバック配線ではなく、**状態遷移**になります。
|
|
32
|
+
|
|
33
|
+
> **自己除外 — ページを 2 つのタブで開くこと。** あるコンテキストは自分自身の post を受信しないため、1 つのタブにある単一の `<wcs-broadcast>` は、自分の `post` が `message` に反映されるのを見ることはありません。往復は、**別の**コンテキスト(別のタブ、または同じチャンネル名の別の `<wcs-broadcast>`)が listen しているときにのみ閉じます。この README のデモは、ページが 2 つのタブで開かれていることを前提とします。
|
|
34
|
+
|
|
35
|
+
> **同一オリジンのみ、structured clone。** BroadcastChannel は 1 つのオリジン内で動作します。ペイロードはブラウザの structured clone に乗るため、オブジェクトはそのまま渡されます — **JSON の往復は無く**、文字列化も不要です。クローン不可能なペイロード(関数、DOM ノード)は throw せず `error` プロパティを通じて `DataCloneError` を表面化します。
|
|
36
|
+
|
|
37
|
+
## インストール
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm install @wcstack/broadcast
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## クイックスタート
|
|
44
|
+
|
|
45
|
+
### 1. メッセージを送信(post)
|
|
46
|
+
|
|
47
|
+
post は DOM クリック(autoTrigger)または command-token から起動します。
|
|
48
|
+
|
|
49
|
+
```html
|
|
50
|
+
<script type="module" src="https://esm.run/@wcstack/state/auto"></script>
|
|
51
|
+
<script type="module" src="https://esm.run/@wcstack/broadcast/auto"></script>
|
|
52
|
+
|
|
53
|
+
<wcs-broadcast id="bc" name="room"></wcs-broadcast>
|
|
54
|
+
|
|
55
|
+
<!-- 任意の DOM トリガ: クリックでリテラルテキストを post -->
|
|
56
|
+
<input id="msg" value="hello" />
|
|
57
|
+
<button data-broadcast-target="bc" data-broadcast-from="#msg">Send</button>
|
|
58
|
+
<button data-broadcast-target="bc" data-broadcast-text="ping">Ping</button>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`data-broadcast-text` はリテラル文字列を post します。`data-broadcast-from` はセレクタにマッチした要素の `value`(なければ `textContent`)を post します。
|
|
62
|
+
|
|
63
|
+
### 2. タブ間カウンタ(command-token + event-token)
|
|
64
|
+
|
|
65
|
+
1 つの要素に双対性が同居します。`post` は command-token から配線され、受信する `message` は event-token 経由で受け取ります。これを 2 つのタブで開いて "Bump" をクリックすると、各タブのカウントが同期します。
|
|
66
|
+
|
|
67
|
+
```html
|
|
68
|
+
<wcs-state>
|
|
69
|
+
<script type="module">
|
|
70
|
+
export default {
|
|
71
|
+
count: 0,
|
|
72
|
+
$commandTokens: ["send"],
|
|
73
|
+
$eventTokens: ["onMessage"],
|
|
74
|
+
bump() {
|
|
75
|
+
this.count = this.count + 1;
|
|
76
|
+
this.$command.send.emit(this.count); // state → element → 他のタブ
|
|
77
|
+
},
|
|
78
|
+
$on: {
|
|
79
|
+
onMessage: (state, event) => { // 他のタブ → element → state
|
|
80
|
+
state.count = event.detail;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
</script>
|
|
85
|
+
</wcs-state>
|
|
86
|
+
|
|
87
|
+
<wcs-broadcast name="counter" data-wcs="
|
|
88
|
+
command.post: $command.send;
|
|
89
|
+
eventToken.message: onMessage
|
|
90
|
+
"></wcs-broadcast>
|
|
91
|
+
|
|
92
|
+
<button data-wcs="onclick: bump">Bump</button>
|
|
93
|
+
<p data-wcs="textContent: count"></p>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### 3. 受信した値を state にミラーする
|
|
97
|
+
|
|
98
|
+
最新のメッセージを*読む*だけなら event-token は不要です — `message` を直接バインドしてください。
|
|
99
|
+
|
|
100
|
+
```html
|
|
101
|
+
<wcs-state>
|
|
102
|
+
<script type="module">
|
|
103
|
+
export default { incoming: null };
|
|
104
|
+
</script>
|
|
105
|
+
</wcs-state>
|
|
106
|
+
|
|
107
|
+
<wcs-broadcast name="room" data-wcs="message: incoming"></wcs-broadcast>
|
|
108
|
+
<p data-wcs="textContent: incoming"></p>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## 属性 / 入力(Attributes / Inputs)
|
|
112
|
+
|
|
113
|
+
| 属性 | 型 | 既定値 | 説明 |
|
|
114
|
+
| --------- | ------- | ------- | ---------------------------------------------------------------------------- |
|
|
115
|
+
| `name` | string | `""` | 参加するチャンネル名。変更すると新しいチャンネルで開き直す。 |
|
|
116
|
+
| `manual` | boolean | `false` | 接続時や `name` 変更時にチャンネルを自動で開かない。代わりに `open()` を呼ぶ。接続時と各 `name` 変更時に評価される。`observedAttributes` に**含まれない**ため、既に接続済みの要素で `manual` をトグルしても即座の効果はない(*次回*の接続や `name` 変更時の挙動を変えるだけ)。 |
|
|
117
|
+
|
|
118
|
+
### DOM トリガ属性(autoTrigger、クリックで post)
|
|
119
|
+
|
|
120
|
+
| 属性 | 付与先 | 説明 |
|
|
121
|
+
| ----------------------- | -------------- | ----------------------------------------------------------------------- |
|
|
122
|
+
| `data-broadcast-target` | トリガボタン | 駆動する `<wcs-broadcast>` の id。 |
|
|
123
|
+
| `data-broadcast-text` | トリガボタン | post するリテラルテキスト(優先される。空文字列も有効)。 |
|
|
124
|
+
| `data-broadcast-from` | トリガボタン | CSS セレクタ。マッチした要素の `value`(なければ `textContent`)を post。 |
|
|
125
|
+
|
|
126
|
+
> DOM トリガによる `post` は fire-and-forget で、決して reject しません。post 失敗(例: クローン不可能なペイロード — 文字列しか post しない DOM トリガからは発生しない)は `error` プロパティを通じて現れます。
|
|
127
|
+
|
|
128
|
+
## 観測可能なプロパティ(出力)
|
|
129
|
+
|
|
130
|
+
| プロパティ | イベント | 説明 |
|
|
131
|
+
| --------- | ------------------------ | ------------------------------------------------------------------------------------ |
|
|
132
|
+
| `message` | `wcs-broadcast:message` | チャンネル上の他のコンテキストから受信した直近の値(structured clone のコピー)。このコンテキスト自身の post では決して設定されない。 |
|
|
133
|
+
| `error` | `wcs-broadcast:error` | 正規化された `{ name, message }` — `DataCloneError`(クローン不可能な post)、`DataError`(ピアのメッセージをデシリアライズできなかった)、`InvalidStateError`(開いているチャンネルが無い状態での post)、または `NotSupportedError`(BroadcastChannel が利用不可)。 |
|
|
134
|
+
|
|
135
|
+
## コマンド
|
|
136
|
+
|
|
137
|
+
| コマンド | 説明 |
|
|
138
|
+
| ------- | --------------------------------------------------------------------------------------- |
|
|
139
|
+
| `open` | `name` 属性で指定されたチャンネルに参加する(既に開いているチャンネルは閉じる)。 |
|
|
140
|
+
| `post` | structured clone 可能な値を他のすべてのコンテキストに post する(reject しない — 失敗は `error` へ)。 |
|
|
141
|
+
| `close` | チャンネルから離脱する(冪等)。 |
|
|
142
|
+
|
|
143
|
+
状態からの起動には command-token プロトコルを使います。
|
|
144
|
+
|
|
145
|
+
```html
|
|
146
|
+
<wcs-broadcast name="room" data-wcs="command.post: $command.send"></wcs-broadcast>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## 注意点と制約
|
|
150
|
+
|
|
151
|
+
- **自己除外は意図的。** あるコンテキストは自分自身の post を決して受信しません — これは BroadcastChannel の契約であり、バグではありません。往復を見るには、第 2 のコンテキスト(タブ/iframe/worker、または同じチャンネル名の第 2 の `<wcs-broadcast>`)を listen させてください。*同じ*タブ内の 2 つの `<wcs-broadcast name="x">` 要素は互いに受信します(別々のチャンネルオブジェクトだから)。単一の要素が自分自身に話しかける場合だけは受信しません。
|
|
152
|
+
- **`name` は監視される。** `<wcs-clipboard>` と異なり、`<wcs-broadcast>` は `name` に対して `observedAttributes` を実装します。接続中(かつ非 `manual`)に `name` 属性を変更すると、古いチャンネルを閉じて新しいチャンネルを開きます。`name` をクリアする(空文字列にする、または属性を削除する)のは close *ではありません*。別の `name` に切り替えるか、明示的に `close()` を呼ぶまで、以前開いたチャンネルは維持されます。空でない新しい値だけが切り替えをトリガします。
|
|
153
|
+
- **ワイヤエンコーディングは無い。** ペイロードは structured clone を使うため、JSON の stringify/parse ステップはありません(テキストワイヤで送る `<wcs-ws>` と異なります)。オブジェクトを直接 post すると、受信側はディープコピーを得ます。クローン不可能な値は `error` 経由で `DataCloneError` として失敗します。
|
|
154
|
+
- **接続状態は無い。** BroadcastChannel は構築された瞬間に「開いて」います — connecting/ハンドシェイク段階も、`readyState` も、再接続もありません(必要ないため)。Shell は接続時に同期的に開くので、`connectedCallbackPromise` / SSR スナップショットはありません。
|
|
155
|
+
- **再接続で開き直す。** 要素を削除して再挿入すると `connectedCallback` が再度実行され、`name` 属性(真実の源)からチャンネルを開き直します。`disconnectedCallback` がそれを閉じます。
|
|
156
|
+
- **無言のエラー処理(ゼロログ)。** wcstack 全体のゼロ依存主義に従い、`<wcs-broadcast>` は実行時の失敗に対して一切ログ出力も throw もしません。BroadcastChannel コンストラクタの欠如、クローン不可能な post、デシリアライズ失敗は `error` プロパティ / `wcs-broadcast:error` イベントを通じてのみ表面化します — `post()` は resolve し、決して reject しません。観測・対処するには `error` をバインドしてください。
|
|
157
|
+
|
|
158
|
+
## ヘッドレス利用(`BroadcastCore`)
|
|
159
|
+
|
|
160
|
+
Core はグローバルな `BroadcastChannel` 以外に DOM 依存を持たず、`@wc-bindable/core` の `bind()` と直接組み合わせて使えます。
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
import { BroadcastCore } from "@wcstack/broadcast";
|
|
164
|
+
|
|
165
|
+
const bus = new BroadcastCore();
|
|
166
|
+
bus.addEventListener("wcs-broadcast:message", (e) => {
|
|
167
|
+
console.log((e as CustomEvent).detail); // 受信した値
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
bus.open("room");
|
|
171
|
+
bus.post({ type: "hello", at: Date.now() });
|
|
172
|
+
// ...後で
|
|
173
|
+
bus.close();
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## ライセンス
|
|
177
|
+
|
|
178
|
+
MIT
|
package/README.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# @wcstack/broadcast
|
|
2
|
+
|
|
3
|
+
`@wcstack/broadcast` is a headless cross-tab messaging component for the wcstack ecosystem.
|
|
4
|
+
|
|
5
|
+
It is not a visual UI widget.
|
|
6
|
+
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.
|
|
7
|
+
|
|
8
|
+
`<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:
|
|
9
|
+
|
|
10
|
+
- **post** (`state → element`) via the command-token protocol — `command.post: $command.send`
|
|
11
|
+
- **message** (`element → state`) via the event-token protocol — `eventToken.message: onMessage`
|
|
12
|
+
|
|
13
|
+
With `@wcstack/state`, `<wcs-broadcast>` can be bound directly through path contracts:
|
|
14
|
+
|
|
15
|
+
- **input surface**: `name`, `manual`
|
|
16
|
+
- **command surface**: `open`, `post`, `close`
|
|
17
|
+
- **output state surface**: `message`, `error`
|
|
18
|
+
|
|
19
|
+
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.
|
|
20
|
+
|
|
21
|
+
`@wcstack/broadcast` follows the [CSBC](https://github.com/csbc-dev/arch/blob/main/README.md) (Core / Shell / Binding Contract) architecture:
|
|
22
|
+
|
|
23
|
+
- **Core** (`BroadcastCore`) handles channel lifecycle, posting, structured-clone receipt, and error handling
|
|
24
|
+
- **Shell** (`<wcs-broadcast>`) connects that state to DOM attributes, lifecycle, and declarative commands
|
|
25
|
+
- **Binding Contract** (`static wcBindable`) declares observable `properties`, writable `inputs`, and callable `commands`
|
|
26
|
+
|
|
27
|
+
## Why this exists
|
|
28
|
+
|
|
29
|
+
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.
|
|
30
|
+
|
|
31
|
+
`@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.
|
|
32
|
+
|
|
33
|
+
> **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 own `post` reflected back into `message`. 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.
|
|
34
|
+
|
|
35
|
+
> **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 `DataCloneError` through the `error` property rather than throwing.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm install @wcstack/broadcast
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
45
|
+
### 1. Send a message (post)
|
|
46
|
+
|
|
47
|
+
Drive a post from a DOM click (autoTrigger) or a command-token.
|
|
48
|
+
|
|
49
|
+
```html
|
|
50
|
+
<script type="module" src="https://esm.run/@wcstack/state/auto"></script>
|
|
51
|
+
<script type="module" src="https://esm.run/@wcstack/broadcast/auto"></script>
|
|
52
|
+
|
|
53
|
+
<wcs-broadcast id="bc" name="room"></wcs-broadcast>
|
|
54
|
+
|
|
55
|
+
<!-- Optional DOM triggering: click posts the literal text -->
|
|
56
|
+
<input id="msg" value="hello" />
|
|
57
|
+
<button data-broadcast-target="bc" data-broadcast-from="#msg">Send</button>
|
|
58
|
+
<button data-broadcast-target="bc" data-broadcast-text="ping">Ping</button>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`data-broadcast-text` posts a literal string; `data-broadcast-from` posts the `value` (or `textContent`) of the element matched by the selector.
|
|
62
|
+
|
|
63
|
+
### 2. Cross-tab counter (command-token + event-token)
|
|
64
|
+
|
|
65
|
+
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.
|
|
66
|
+
|
|
67
|
+
```html
|
|
68
|
+
<wcs-state>
|
|
69
|
+
<script type="module">
|
|
70
|
+
export default {
|
|
71
|
+
count: 0,
|
|
72
|
+
$commandTokens: ["send"],
|
|
73
|
+
$eventTokens: ["onMessage"],
|
|
74
|
+
bump() {
|
|
75
|
+
this.count = this.count + 1;
|
|
76
|
+
this.$command.send.emit(this.count); // state → element → other tabs
|
|
77
|
+
},
|
|
78
|
+
$on: {
|
|
79
|
+
onMessage: (state, event) => { // other tabs → element → state
|
|
80
|
+
state.count = event.detail;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
</script>
|
|
85
|
+
</wcs-state>
|
|
86
|
+
|
|
87
|
+
<wcs-broadcast name="counter" data-wcs="
|
|
88
|
+
command.post: $command.send;
|
|
89
|
+
eventToken.message: onMessage
|
|
90
|
+
"></wcs-broadcast>
|
|
91
|
+
|
|
92
|
+
<button data-wcs="onclick: bump">Bump</button>
|
|
93
|
+
<p data-wcs="textContent: count"></p>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### 3. Mirror a received value into state
|
|
97
|
+
|
|
98
|
+
You do not need the event-token to *read* the latest message — bind `message` directly.
|
|
99
|
+
|
|
100
|
+
```html
|
|
101
|
+
<wcs-state>
|
|
102
|
+
<script type="module">
|
|
103
|
+
export default { incoming: null };
|
|
104
|
+
</script>
|
|
105
|
+
</wcs-state>
|
|
106
|
+
|
|
107
|
+
<wcs-broadcast name="room" data-wcs="message: incoming"></wcs-broadcast>
|
|
108
|
+
<p data-wcs="textContent: incoming"></p>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Attributes / Inputs
|
|
112
|
+
|
|
113
|
+
| Attribute | Type | Default | Description |
|
|
114
|
+
| --------- | ------- | ------- | ---------------------------------------------------------------------------- |
|
|
115
|
+
| `name` | string | `""` | The channel name to join. Changing it re-opens on the new channel. |
|
|
116
|
+
| `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). |
|
|
117
|
+
|
|
118
|
+
### DOM trigger attributes (autoTrigger, post-on-click)
|
|
119
|
+
|
|
120
|
+
| Attribute | On | Description |
|
|
121
|
+
| ----------------------- | -------------- | ----------------------------------------------------------------------- |
|
|
122
|
+
| `data-broadcast-target` | trigger button | Id of the `<wcs-broadcast>` to drive. |
|
|
123
|
+
| `data-broadcast-text` | trigger button | Literal text to post (takes precedence; empty string is valid). |
|
|
124
|
+
| `data-broadcast-from` | trigger button | CSS selector; posts the matched element's `value` (or `textContent`). |
|
|
125
|
+
|
|
126
|
+
> A DOM-triggered `post` is 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 the `error` property.
|
|
127
|
+
|
|
128
|
+
## Observable Properties (outputs)
|
|
129
|
+
|
|
130
|
+
| Property | Event | Description |
|
|
131
|
+
| --------- | ------------------------ | ------------------------------------------------------------------------------------ |
|
|
132
|
+
| `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. |
|
|
133
|
+
| `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). |
|
|
134
|
+
|
|
135
|
+
## Commands
|
|
136
|
+
|
|
137
|
+
| Command | Description |
|
|
138
|
+
| ------- | --------------------------------------------------------------------------------------- |
|
|
139
|
+
| `open` | Join the channel named by the `name` attribute (closes any previously-open channel). |
|
|
140
|
+
| `post` | Post a structured-cloneable value to every other context (never rejects — failures go to `error`). |
|
|
141
|
+
| `close` | Leave the channel (idempotent). |
|
|
142
|
+
|
|
143
|
+
State-driven invocation uses the command-token protocol:
|
|
144
|
+
|
|
145
|
+
```html
|
|
146
|
+
<wcs-broadcast name="room" data-wcs="command.post: $command.send"></wcs-broadcast>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Notes & limitations
|
|
150
|
+
|
|
151
|
+
- **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.
|
|
152
|
+
- **`name` is observed.** Unlike `<wcs-clipboard>`, `<wcs-broadcast>` implements `observedAttributes` for `name`: changing the `name` attribute while connected (and not `manual`) closes the old channel and opens the new one. Clearing the `name` (setting it to an empty string or removing the attribute) is *not* a close: the previously-open channel is kept until you switch to another `name` or call `close()` explicitly. Only a non-empty new value triggers the switch.
|
|
153
|
+
- **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 with `DataCloneError` via `error`.
|
|
154
|
+
- **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 there is no `connectedCallbackPromise` / SSR snapshot.
|
|
155
|
+
- **Reconnect re-opens.** Removing and re-inserting the element runs `connectedCallback` again, re-opening the channel from the `name` attribute (the source of truth), and `disconnectedCallback` closes it.
|
|
156
|
+
- **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 the `error` property / `wcs-broadcast:error` event — `post()` resolves and never rejects. Bind `error` to observe and react.
|
|
157
|
+
|
|
158
|
+
## Headless usage (`BroadcastCore`)
|
|
159
|
+
|
|
160
|
+
The Core has no DOM dependency beyond the global `BroadcastChannel` and can be used directly with `bind()` from `@wc-bindable/core`:
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
import { BroadcastCore } from "@wcstack/broadcast";
|
|
164
|
+
|
|
165
|
+
const bus = new BroadcastCore();
|
|
166
|
+
bus.addEventListener("wcs-broadcast:message", (e) => {
|
|
167
|
+
console.log((e as CustomEvent).detail); // the received value
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
bus.open("room");
|
|
171
|
+
bus.post({ type: "hello", at: Date.now() });
|
|
172
|
+
// ...later
|
|
173
|
+
bus.close();
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## License
|
|
177
|
+
|
|
178
|
+
MIT
|
package/dist/auto.js
ADDED
package/dist/auto.min.js
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
interface ITagNames {
|
|
2
|
+
readonly broadcast: string;
|
|
3
|
+
}
|
|
4
|
+
interface IWritableTagNames {
|
|
5
|
+
broadcast?: string;
|
|
6
|
+
}
|
|
7
|
+
interface IConfig {
|
|
8
|
+
readonly autoTrigger: boolean;
|
|
9
|
+
readonly triggerAttribute: string;
|
|
10
|
+
readonly tagNames: ITagNames;
|
|
11
|
+
}
|
|
12
|
+
interface IWritableConfig {
|
|
13
|
+
autoTrigger?: boolean;
|
|
14
|
+
triggerAttribute?: string;
|
|
15
|
+
tagNames?: IWritableTagNames;
|
|
16
|
+
}
|
|
17
|
+
interface IWcBindableProperty {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly event: string;
|
|
20
|
+
readonly getter?: (event: Event) => any;
|
|
21
|
+
}
|
|
22
|
+
interface IWcBindableInput {
|
|
23
|
+
readonly name: string;
|
|
24
|
+
readonly attribute?: string;
|
|
25
|
+
}
|
|
26
|
+
interface IWcBindableCommand {
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly async?: boolean;
|
|
29
|
+
}
|
|
30
|
+
interface IWcBindable {
|
|
31
|
+
readonly protocol: "wc-bindable";
|
|
32
|
+
readonly version: number;
|
|
33
|
+
readonly properties: IWcBindableProperty[];
|
|
34
|
+
readonly inputs?: IWcBindableInput[];
|
|
35
|
+
readonly commands?: IWcBindableCommand[];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Normalized BroadcastChannel failure. `name` mirrors the `DOMException.name`
|
|
39
|
+
* (e.g. `DataCloneError` when a posted value is not structured-cloneable,
|
|
40
|
+
* `DataError` for a `messageerror` deserialization failure); `unsupported` is
|
|
41
|
+
* surfaced as `NotSupportedError` when the `BroadcastChannel` constructor is
|
|
42
|
+
* absent (older browsers, or a non-window environment).
|
|
43
|
+
*/
|
|
44
|
+
interface WcsBroadcastErrorDetail {
|
|
45
|
+
name: string;
|
|
46
|
+
message: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Value types for BroadcastCore (headless) — the observable state properties.
|
|
50
|
+
* Use with `bind()` from `@wc-bindable/core` for compile-time type checking.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```typescript
|
|
54
|
+
* const core = new BroadcastCore();
|
|
55
|
+
* bind(core, (name: keyof WcsBroadcastCoreValues, value) => { ... });
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
interface WcsBroadcastCoreValues {
|
|
59
|
+
/**
|
|
60
|
+
* The most recent message received from *another* same-origin context on the
|
|
61
|
+
* channel. A context never receives its own posts (BroadcastChannel
|
|
62
|
+
* self-exclusion), so within a single tab `message` only updates from another
|
|
63
|
+
* `<wcs-broadcast>` on the same channel name. The value is whatever was
|
|
64
|
+
* posted, reconstructed via structured clone (no JSON round-trip).
|
|
65
|
+
*/
|
|
66
|
+
message: any;
|
|
67
|
+
/** The last error (post failure / deserialization failure / unsupported), or `null`. */
|
|
68
|
+
error: WcsBroadcastErrorDetail | null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Value types for the Shell (`<wcs-broadcast>`) — identical observable surface
|
|
72
|
+
* to the Core.
|
|
73
|
+
*/
|
|
74
|
+
type WcsBroadcastValues = WcsBroadcastCoreValues;
|
|
75
|
+
interface WcsBroadcastInputs {
|
|
76
|
+
/** The channel name to join. Changing it re-opens on the new channel. */
|
|
77
|
+
name: string;
|
|
78
|
+
/**
|
|
79
|
+
* When present, do NOT open the channel automatically on connect (or when the
|
|
80
|
+
* `name` attribute changes). Open imperatively via `open()` instead.
|
|
81
|
+
*/
|
|
82
|
+
manual: boolean;
|
|
83
|
+
}
|
|
84
|
+
interface WcsBroadcastCoreCommands {
|
|
85
|
+
open(name: string): void;
|
|
86
|
+
post(data: any): void;
|
|
87
|
+
close(): void;
|
|
88
|
+
}
|
|
89
|
+
/** Commands exposed on the Shell — `open()` reads the `name` attribute. */
|
|
90
|
+
interface WcsBroadcastCommands {
|
|
91
|
+
open(): void;
|
|
92
|
+
post(data: any): void;
|
|
93
|
+
close(): void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
declare function bootstrapBroadcast(userConfig?: IWritableConfig): void;
|
|
97
|
+
|
|
98
|
+
declare function getConfig(): IConfig;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Headless cross-tab messaging primitive. A thin, framework-agnostic wrapper
|
|
102
|
+
* around the BroadcastChannel API exposed through the wc-bindable protocol.
|
|
103
|
+
*
|
|
104
|
+
* BroadcastChannel is a same-origin pub/sub bus: every context (tab, iframe,
|
|
105
|
+
* worker) that opens a channel with the same `name` receives every other
|
|
106
|
+
* context's posts — but NOT its own. This self-exclusion is the whole point:
|
|
107
|
+
* `post` is a `state → element` action (command-token) and an incoming
|
|
108
|
+
* `message` is an `element → state` notification (event-token), but the two
|
|
109
|
+
* only close the loop *across* a context boundary. Within a single tab a lone
|
|
110
|
+
* `<wcs-broadcast>` never hears itself; open the page in two tabs to see the
|
|
111
|
+
* round-trip.
|
|
112
|
+
*
|
|
113
|
+
* Unlike WebSocketCore there is no connection state, no reconnect, and no wire
|
|
114
|
+
* encoding: a channel is "open" the moment it is constructed, and payloads ride
|
|
115
|
+
* the browser's structured clone (objects pass through as-is, no JSON
|
|
116
|
+
* round-trip). The only failure surfaces are a non-cloneable `post`
|
|
117
|
+
* (`DataCloneError`), a `messageerror` (a peer posted something this context
|
|
118
|
+
* cannot deserialize), and an absent `BroadcastChannel` constructor
|
|
119
|
+
* (`unsupported`). All three flow through the `error` property — the Core never
|
|
120
|
+
* throws — symmetrical with FetchCore / ClipboardCore.
|
|
121
|
+
*/
|
|
122
|
+
declare class BroadcastCore extends EventTarget {
|
|
123
|
+
static wcBindable: IWcBindable;
|
|
124
|
+
private _target;
|
|
125
|
+
private _channel;
|
|
126
|
+
private _name;
|
|
127
|
+
private _message;
|
|
128
|
+
private _error;
|
|
129
|
+
constructor(target?: EventTarget);
|
|
130
|
+
get message(): any;
|
|
131
|
+
get error(): WcsBroadcastErrorDetail | null;
|
|
132
|
+
private _setMessage;
|
|
133
|
+
private _setError;
|
|
134
|
+
/**
|
|
135
|
+
* Join the named channel. Any previously-open channel is closed first, so
|
|
136
|
+
* calling `open()` again switches channels. When the BroadcastChannel
|
|
137
|
+
* constructor is unavailable this surfaces an `unsupported` error and leaves
|
|
138
|
+
* the Core channel-less (a later `post()` then errors loudly rather than
|
|
139
|
+
* silently dropping).
|
|
140
|
+
*/
|
|
141
|
+
open(name: string): void;
|
|
142
|
+
/**
|
|
143
|
+
* Post a structured-cloneable value to every other context on the channel.
|
|
144
|
+
* The local context never receives it (self-exclusion). Never throws:
|
|
145
|
+
* a non-cloneable value surfaces as a `DataCloneError` through `error`, and
|
|
146
|
+
* posting with no open channel surfaces an `InvalidStateError`.
|
|
147
|
+
*/
|
|
148
|
+
post(data: any): void;
|
|
149
|
+
/** Leave the channel. Idempotent — a no-op when no channel is open. */
|
|
150
|
+
close(): void;
|
|
151
|
+
/**
|
|
152
|
+
* Tear the Core down for a disconnected Shell: close the channel and reset the
|
|
153
|
+
* error shadow silently (no dispatch on a torn-down element). A later
|
|
154
|
+
* reconnect re-opens via the Shell's connectedCallback.
|
|
155
|
+
*
|
|
156
|
+
* Asymmetry by design: `_message` is deliberately NOT reset. `error` is
|
|
157
|
+
* transient connection state — a stale error from a previous channel would be
|
|
158
|
+
* misleading after a reconnect, so it is cleared. `message` is the last value
|
|
159
|
+
* received (an event payload), not connection state; it is retained as the
|
|
160
|
+
* Core's last-known datum so a binding still reads it across a disconnect/
|
|
161
|
+
* reconnect, and it is naturally overwritten by the next incoming message.
|
|
162
|
+
*/
|
|
163
|
+
dispose(): void;
|
|
164
|
+
private _onMessage;
|
|
165
|
+
private _onMessageError;
|
|
166
|
+
private _closeChannel;
|
|
167
|
+
private _hasBroadcastChannel;
|
|
168
|
+
private _normalizeError;
|
|
169
|
+
private _unsupportedError;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
declare class WcsBroadcast extends HTMLElement {
|
|
173
|
+
static hasConnectedCallbackPromise: boolean;
|
|
174
|
+
static wcBindable: IWcBindable;
|
|
175
|
+
static get observedAttributes(): string[];
|
|
176
|
+
private _core;
|
|
177
|
+
constructor();
|
|
178
|
+
get name(): string;
|
|
179
|
+
set name(value: string);
|
|
180
|
+
get manual(): boolean;
|
|
181
|
+
set manual(value: boolean);
|
|
182
|
+
get message(): any;
|
|
183
|
+
get error(): WcsBroadcastErrorDetail | null;
|
|
184
|
+
open(): void;
|
|
185
|
+
post(data: any): void;
|
|
186
|
+
close(): void;
|
|
187
|
+
attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null): void;
|
|
188
|
+
connectedCallback(): void;
|
|
189
|
+
disconnectedCallback(): void;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export { BroadcastCore, WcsBroadcast, bootstrapBroadcast, getConfig };
|
|
193
|
+
export type { IWritableConfig, IWritableTagNames, WcsBroadcastCommands, WcsBroadcastCoreCommands, WcsBroadcastCoreValues, WcsBroadcastErrorDetail, WcsBroadcastInputs, WcsBroadcastValues };
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
const _config = {
|
|
2
|
+
autoTrigger: true,
|
|
3
|
+
triggerAttribute: "data-broadcast-target",
|
|
4
|
+
tagNames: {
|
|
5
|
+
broadcast: "wcs-broadcast",
|
|
6
|
+
},
|
|
7
|
+
};
|
|
8
|
+
function deepFreeze(obj) {
|
|
9
|
+
if (obj === null || typeof obj !== "object")
|
|
10
|
+
return obj;
|
|
11
|
+
Object.freeze(obj);
|
|
12
|
+
for (const key of Object.keys(obj)) {
|
|
13
|
+
deepFreeze(obj[key]);
|
|
14
|
+
}
|
|
15
|
+
return obj;
|
|
16
|
+
}
|
|
17
|
+
function deepClone(obj) {
|
|
18
|
+
if (obj === null || typeof obj !== "object")
|
|
19
|
+
return obj;
|
|
20
|
+
const clone = {};
|
|
21
|
+
for (const key of Object.keys(obj)) {
|
|
22
|
+
clone[key] = deepClone(obj[key]);
|
|
23
|
+
}
|
|
24
|
+
return clone;
|
|
25
|
+
}
|
|
26
|
+
let frozenConfig = null;
|
|
27
|
+
// Live reference to the mutable internal config: reads always reflect the latest
|
|
28
|
+
// setConfig() call. The readonly IConfig type only blocks callers from writing
|
|
29
|
+
// through it — the underlying object still changes. If you need a stable,
|
|
30
|
+
// frozen snapshot that won't move under you, use getConfig() instead.
|
|
31
|
+
const config = _config;
|
|
32
|
+
function getConfig() {
|
|
33
|
+
if (!frozenConfig) {
|
|
34
|
+
frozenConfig = deepFreeze(deepClone(_config));
|
|
35
|
+
}
|
|
36
|
+
return frozenConfig;
|
|
37
|
+
}
|
|
38
|
+
function setConfig(partialConfig) {
|
|
39
|
+
if (typeof partialConfig.autoTrigger === "boolean") {
|
|
40
|
+
_config.autoTrigger = partialConfig.autoTrigger;
|
|
41
|
+
}
|
|
42
|
+
if (typeof partialConfig.triggerAttribute === "string") {
|
|
43
|
+
_config.triggerAttribute = partialConfig.triggerAttribute;
|
|
44
|
+
}
|
|
45
|
+
if (partialConfig.tagNames) {
|
|
46
|
+
Object.assign(_config.tagNames, partialConfig.tagNames);
|
|
47
|
+
}
|
|
48
|
+
frozenConfig = null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Headless cross-tab messaging primitive. A thin, framework-agnostic wrapper
|
|
53
|
+
* around the BroadcastChannel API exposed through the wc-bindable protocol.
|
|
54
|
+
*
|
|
55
|
+
* BroadcastChannel is a same-origin pub/sub bus: every context (tab, iframe,
|
|
56
|
+
* worker) that opens a channel with the same `name` receives every other
|
|
57
|
+
* context's posts — but NOT its own. This self-exclusion is the whole point:
|
|
58
|
+
* `post` is a `state → element` action (command-token) and an incoming
|
|
59
|
+
* `message` is an `element → state` notification (event-token), but the two
|
|
60
|
+
* only close the loop *across* a context boundary. Within a single tab a lone
|
|
61
|
+
* `<wcs-broadcast>` never hears itself; open the page in two tabs to see the
|
|
62
|
+
* round-trip.
|
|
63
|
+
*
|
|
64
|
+
* Unlike WebSocketCore there is no connection state, no reconnect, and no wire
|
|
65
|
+
* encoding: a channel is "open" the moment it is constructed, and payloads ride
|
|
66
|
+
* the browser's structured clone (objects pass through as-is, no JSON
|
|
67
|
+
* round-trip). The only failure surfaces are a non-cloneable `post`
|
|
68
|
+
* (`DataCloneError`), a `messageerror` (a peer posted something this context
|
|
69
|
+
* cannot deserialize), and an absent `BroadcastChannel` constructor
|
|
70
|
+
* (`unsupported`). All three flow through the `error` property — the Core never
|
|
71
|
+
* throws — symmetrical with FetchCore / ClipboardCore.
|
|
72
|
+
*/
|
|
73
|
+
class BroadcastCore extends EventTarget {
|
|
74
|
+
static wcBindable = {
|
|
75
|
+
protocol: "wc-bindable",
|
|
76
|
+
version: 1,
|
|
77
|
+
properties: [
|
|
78
|
+
{ name: "message", event: "wcs-broadcast:message" },
|
|
79
|
+
{ name: "error", event: "wcs-broadcast:error" },
|
|
80
|
+
],
|
|
81
|
+
commands: [
|
|
82
|
+
{ name: "open" },
|
|
83
|
+
{ name: "post" },
|
|
84
|
+
{ name: "close" },
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
_target;
|
|
88
|
+
_channel = null;
|
|
89
|
+
_name = null;
|
|
90
|
+
_message = null;
|
|
91
|
+
_error = null;
|
|
92
|
+
constructor(target) {
|
|
93
|
+
super();
|
|
94
|
+
this._target = target ?? this;
|
|
95
|
+
}
|
|
96
|
+
get message() {
|
|
97
|
+
return this._message;
|
|
98
|
+
}
|
|
99
|
+
get error() {
|
|
100
|
+
return this._error;
|
|
101
|
+
}
|
|
102
|
+
// --- State setters with event dispatch ---
|
|
103
|
+
// Deliberately NO same-value guard (unlike `error` below). A received message
|
|
104
|
+
// is an event, not idempotent state: a peer posting the same value twice is
|
|
105
|
+
// two distinct occurrences and must re-fire wcs-broadcast:message each time so
|
|
106
|
+
// a `message:` binding and any `eventToken.message:` subscriber see both.
|
|
107
|
+
_setMessage(message) {
|
|
108
|
+
this._message = message;
|
|
109
|
+
this._target.dispatchEvent(new CustomEvent("wcs-broadcast:message", {
|
|
110
|
+
detail: message,
|
|
111
|
+
bubbles: true,
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
_setError(error) {
|
|
115
|
+
// Same-value guard. `error` has no derived state, so suppressing redundant
|
|
116
|
+
// null→null dispatches (e.g. a successful open clearing an already-null
|
|
117
|
+
// error) avoids spurious events. Reference identity is sufficient: each
|
|
118
|
+
// failure builds a fresh object, and the clear path always passes null.
|
|
119
|
+
if (this._error === error)
|
|
120
|
+
return;
|
|
121
|
+
this._error = error;
|
|
122
|
+
this._target.dispatchEvent(new CustomEvent("wcs-broadcast:error", {
|
|
123
|
+
detail: error,
|
|
124
|
+
bubbles: true,
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
// --- Public API ---
|
|
128
|
+
/**
|
|
129
|
+
* Join the named channel. Any previously-open channel is closed first, so
|
|
130
|
+
* calling `open()` again switches channels. When the BroadcastChannel
|
|
131
|
+
* constructor is unavailable this surfaces an `unsupported` error and leaves
|
|
132
|
+
* the Core channel-less (a later `post()` then errors loudly rather than
|
|
133
|
+
* silently dropping).
|
|
134
|
+
*/
|
|
135
|
+
open(name) {
|
|
136
|
+
if (!this._hasBroadcastChannel()) {
|
|
137
|
+
this._setError(this._unsupportedError());
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// Idempotent on the same channel: re-opening the channel we are already on
|
|
141
|
+
// is pure churn (BroadcastChannel has no reconnect semantics). This also
|
|
142
|
+
// absorbs the custom-element *upgrade* path — when a connected element with
|
|
143
|
+
// a `name` attribute is upgraded (autoloader defines the tag after the
|
|
144
|
+
// markup exists), the spec fires attributeChangedCallback (isConnected ===
|
|
145
|
+
// true) *and* connectedCallback, so the Shell calls open() twice. Without
|
|
146
|
+
// this guard that would create a channel and immediately tear it down.
|
|
147
|
+
if (this._channel && this._name === name)
|
|
148
|
+
return;
|
|
149
|
+
this._closeChannel();
|
|
150
|
+
this._setError(null);
|
|
151
|
+
const channel = new BroadcastChannel(name);
|
|
152
|
+
channel.addEventListener("message", this._onMessage);
|
|
153
|
+
channel.addEventListener("messageerror", this._onMessageError);
|
|
154
|
+
this._channel = channel;
|
|
155
|
+
this._name = name;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Post a structured-cloneable value to every other context on the channel.
|
|
159
|
+
* The local context never receives it (self-exclusion). Never throws:
|
|
160
|
+
* a non-cloneable value surfaces as a `DataCloneError` through `error`, and
|
|
161
|
+
* posting with no open channel surfaces an `InvalidStateError`.
|
|
162
|
+
*/
|
|
163
|
+
post(data) {
|
|
164
|
+
if (!this._hasBroadcastChannel()) {
|
|
165
|
+
this._setError(this._unsupportedError());
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (!this._channel) {
|
|
169
|
+
this._setError({
|
|
170
|
+
name: "InvalidStateError",
|
|
171
|
+
message: "Channel is not open. Call open(name) before post().",
|
|
172
|
+
});
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
this._channel.postMessage(data);
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
this._setError(this._normalizeError(err));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Leave the channel. Idempotent — a no-op when no channel is open. */
|
|
183
|
+
close() {
|
|
184
|
+
this._closeChannel();
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Tear the Core down for a disconnected Shell: close the channel and reset the
|
|
188
|
+
* error shadow silently (no dispatch on a torn-down element). A later
|
|
189
|
+
* reconnect re-opens via the Shell's connectedCallback.
|
|
190
|
+
*
|
|
191
|
+
* Asymmetry by design: `_message` is deliberately NOT reset. `error` is
|
|
192
|
+
* transient connection state — a stale error from a previous channel would be
|
|
193
|
+
* misleading after a reconnect, so it is cleared. `message` is the last value
|
|
194
|
+
* received (an event payload), not connection state; it is retained as the
|
|
195
|
+
* Core's last-known datum so a binding still reads it across a disconnect/
|
|
196
|
+
* reconnect, and it is naturally overwritten by the next incoming message.
|
|
197
|
+
*/
|
|
198
|
+
dispose() {
|
|
199
|
+
this._closeChannel();
|
|
200
|
+
this._error = null;
|
|
201
|
+
}
|
|
202
|
+
// --- Internal ---
|
|
203
|
+
_onMessage = (event) => {
|
|
204
|
+
this._setMessage(event.data);
|
|
205
|
+
};
|
|
206
|
+
// Fired when a peer posted a value this context cannot deserialize. The event
|
|
207
|
+
// carries no usable payload, so report a synthetic DataError.
|
|
208
|
+
_onMessageError = () => {
|
|
209
|
+
this._setError({
|
|
210
|
+
name: "DataError",
|
|
211
|
+
message: "Failed to deserialize a message received on the channel.",
|
|
212
|
+
});
|
|
213
|
+
};
|
|
214
|
+
_closeChannel() {
|
|
215
|
+
if (!this._channel)
|
|
216
|
+
return;
|
|
217
|
+
this._channel.removeEventListener("message", this._onMessage);
|
|
218
|
+
this._channel.removeEventListener("messageerror", this._onMessageError);
|
|
219
|
+
this._channel.close();
|
|
220
|
+
this._channel = null;
|
|
221
|
+
this._name = null;
|
|
222
|
+
}
|
|
223
|
+
_hasBroadcastChannel() {
|
|
224
|
+
return typeof BroadcastChannel !== "undefined";
|
|
225
|
+
}
|
|
226
|
+
_normalizeError(err) {
|
|
227
|
+
if (err instanceof Error) {
|
|
228
|
+
// DOMException is an Error subclass; its `name` (DataCloneError, etc.) is
|
|
229
|
+
// the meaningful discriminator for consumers switching on failure kind.
|
|
230
|
+
return { name: err.name, message: err.message };
|
|
231
|
+
}
|
|
232
|
+
return { name: "Error", message: String(err) };
|
|
233
|
+
}
|
|
234
|
+
_unsupportedError() {
|
|
235
|
+
return {
|
|
236
|
+
name: "NotSupportedError",
|
|
237
|
+
message: "BroadcastChannel is not available in this environment.",
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let registered = false;
|
|
243
|
+
// Attribute names for the optional post-on-click DOM trigger (clipboard.js-style
|
|
244
|
+
// DX). The element carrying `data-broadcast-target` points at a <wcs-broadcast>
|
|
245
|
+
// by id; the text to post comes from either a literal `data-broadcast-text` or
|
|
246
|
+
// a `data-broadcast-from` CSS selector resolving to a source element.
|
|
247
|
+
const TEXT_ATTRIBUTE = "data-broadcast-text";
|
|
248
|
+
const FROM_ATTRIBUTE = "data-broadcast-from";
|
|
249
|
+
function resolveText(triggerElement) {
|
|
250
|
+
// Literal text wins when present (including an empty string — posting "" is a
|
|
251
|
+
// legitimate request). The `?? ""` right-hand side is defensive and
|
|
252
|
+
// unreachable: hasAttribute() just returned true, so getAttribute() cannot be
|
|
253
|
+
// null here. It exists only to satisfy the `string | null` return type — do
|
|
254
|
+
// not chase coverage on it (the DOM contract makes the null branch impossible).
|
|
255
|
+
if (triggerElement.hasAttribute(TEXT_ATTRIBUTE)) {
|
|
256
|
+
return triggerElement.getAttribute(TEXT_ATTRIBUTE) ?? "";
|
|
257
|
+
}
|
|
258
|
+
const selector = triggerElement.getAttribute(FROM_ATTRIBUTE);
|
|
259
|
+
if (!selector)
|
|
260
|
+
return null;
|
|
261
|
+
// A user-authored selector can be syntactically invalid (e.g. `[data-*` or a
|
|
262
|
+
// bare `:not()`), which makes querySelector throw a SyntaxError. Swallow it
|
|
263
|
+
// and treat the source as unresolvable — the same "nothing to post" path as a
|
|
264
|
+
// selector that matches no element — so one bad attribute never crashes the
|
|
265
|
+
// document-level click handler and kills autoTrigger for the whole tab.
|
|
266
|
+
let source;
|
|
267
|
+
try {
|
|
268
|
+
source = document.querySelector(selector);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
if (!source)
|
|
274
|
+
return null;
|
|
275
|
+
// Read a form control's `value`; fall back to text content. A bare
|
|
276
|
+
// `"value" in source` check is too broad — it also matches <button>,
|
|
277
|
+
// <li value>, <progress>, etc. (which carry an unrelated `value`), posting the
|
|
278
|
+
// wrong thing. Narrow to the text-bearing controls a user actually points
|
|
279
|
+
// `data-broadcast-from` at; everything else falls through to textContent.
|
|
280
|
+
if (source instanceof HTMLInputElement ||
|
|
281
|
+
source instanceof HTMLTextAreaElement ||
|
|
282
|
+
source instanceof HTMLSelectElement) {
|
|
283
|
+
return source.value;
|
|
284
|
+
}
|
|
285
|
+
// `?? ""` is defensive: per the DOM spec only Document / DocumentType /
|
|
286
|
+
// Notation nodes have a null `textContent`, and querySelector only ever
|
|
287
|
+
// returns an Element (whose textContent is always a string). The branch is
|
|
288
|
+
// therefore unreachable in practice and kept solely for the `string | null`
|
|
289
|
+
// type — not worth a contrived test.
|
|
290
|
+
return source.textContent ?? "";
|
|
291
|
+
}
|
|
292
|
+
function handleClick(event) {
|
|
293
|
+
const target = event.target;
|
|
294
|
+
if (!(target instanceof Element))
|
|
295
|
+
return;
|
|
296
|
+
const triggerElement = target.closest(`[${config.triggerAttribute}]`);
|
|
297
|
+
if (!triggerElement)
|
|
298
|
+
return;
|
|
299
|
+
const broadcastId = triggerElement.getAttribute(config.triggerAttribute);
|
|
300
|
+
if (!broadcastId)
|
|
301
|
+
return;
|
|
302
|
+
// Resolve the registered constructor at call time instead of importing
|
|
303
|
+
// WcsBroadcast as a value (avoids a components ⇄ autoTrigger import cycle:
|
|
304
|
+
// Broadcast.connectedCallback() calls registerAutoTrigger()). instanceof
|
|
305
|
+
// against the customElements registry keeps the same identity guarantee.
|
|
306
|
+
const BroadcastCtor = customElements.get(config.tagNames.broadcast);
|
|
307
|
+
const broadcastElement = document.getElementById(broadcastId);
|
|
308
|
+
if (!BroadcastCtor || !(broadcastElement instanceof BroadcastCtor))
|
|
309
|
+
return;
|
|
310
|
+
const text = resolveText(triggerElement);
|
|
311
|
+
// No resolvable source: leave the click alone (do not preventDefault) so the
|
|
312
|
+
// element's default action is unaffected.
|
|
313
|
+
if (text === null)
|
|
314
|
+
return;
|
|
315
|
+
// Suppress the default action so a post can run without navigating. Intentional:
|
|
316
|
+
// do not attach data-broadcast-target to an element whose default action you
|
|
317
|
+
// also want (a real <a href> link). See README "Optional DOM Triggering".
|
|
318
|
+
event.preventDefault();
|
|
319
|
+
broadcastElement.post(text);
|
|
320
|
+
}
|
|
321
|
+
function registerAutoTrigger() {
|
|
322
|
+
if (registered)
|
|
323
|
+
return;
|
|
324
|
+
registered = true;
|
|
325
|
+
document.addEventListener("click", handleClick);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Named WcsBroadcast (not `Broadcast`) to match the <wcs-clipboard> WcsClipboard
|
|
329
|
+
// / <wcs-ws> WcsWebSocket convention and avoid shadowing any global.
|
|
330
|
+
class WcsBroadcast extends HTMLElement {
|
|
331
|
+
// The channel opens synchronously in connectedCallback (no async init), so no
|
|
332
|
+
// connectedCallbackPromise is needed — mirrors <wcs-ws>.
|
|
333
|
+
static hasConnectedCallbackPromise = false;
|
|
334
|
+
static wcBindable = {
|
|
335
|
+
...BroadcastCore.wcBindable,
|
|
336
|
+
// Shell-level settable surface. `name` selects the channel; `manual`
|
|
337
|
+
// suppresses auto-open on connect. There is no momentary `post` property:
|
|
338
|
+
// posting needs an argument (the payload), so element actions run via
|
|
339
|
+
// command-token (`command.post: $command.ping`) or the DOM autoTrigger, not
|
|
340
|
+
// a value-derived setter — keeping `post` a plain command keeps the
|
|
341
|
+
// command-token wiring (`command.post:`) readable.
|
|
342
|
+
inputs: [
|
|
343
|
+
{ name: "name", attribute: "name" },
|
|
344
|
+
{ name: "manual", attribute: "manual" },
|
|
345
|
+
],
|
|
346
|
+
// Commands are identical to the Core's — no rename needed since the `name` /
|
|
347
|
+
// `manual` attribute accessors do not collide with open/post/close.
|
|
348
|
+
commands: BroadcastCore.wcBindable.commands,
|
|
349
|
+
};
|
|
350
|
+
static get observedAttributes() { return ["name"]; }
|
|
351
|
+
_core;
|
|
352
|
+
constructor() {
|
|
353
|
+
super();
|
|
354
|
+
this._core = new BroadcastCore(this);
|
|
355
|
+
}
|
|
356
|
+
// --- Attribute accessors ---
|
|
357
|
+
get name() {
|
|
358
|
+
return this.getAttribute("name") || "";
|
|
359
|
+
}
|
|
360
|
+
set name(value) {
|
|
361
|
+
this.setAttribute("name", value);
|
|
362
|
+
}
|
|
363
|
+
get manual() {
|
|
364
|
+
return this.hasAttribute("manual");
|
|
365
|
+
}
|
|
366
|
+
set manual(value) {
|
|
367
|
+
if (value) {
|
|
368
|
+
this.setAttribute("manual", "");
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
this.removeAttribute("manual");
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// --- Core delegated getters ---
|
|
375
|
+
get message() {
|
|
376
|
+
return this._core.message;
|
|
377
|
+
}
|
|
378
|
+
get error() {
|
|
379
|
+
return this._core.error;
|
|
380
|
+
}
|
|
381
|
+
// --- Commands ---
|
|
382
|
+
open() {
|
|
383
|
+
if (this.name) {
|
|
384
|
+
this._core.open(this.name);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
post(data) {
|
|
388
|
+
this._core.post(data);
|
|
389
|
+
}
|
|
390
|
+
close() {
|
|
391
|
+
this._core.close();
|
|
392
|
+
}
|
|
393
|
+
// --- Lifecycle ---
|
|
394
|
+
attributeChangedCallback(name, _oldValue, newValue) {
|
|
395
|
+
if (name === "name" && this.isConnected && !this.manual && newValue) {
|
|
396
|
+
this._core.open(newValue);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
connectedCallback() {
|
|
400
|
+
this.style.display = "none";
|
|
401
|
+
if (config.autoTrigger) {
|
|
402
|
+
registerAutoTrigger();
|
|
403
|
+
}
|
|
404
|
+
if (!this.manual && this.name) {
|
|
405
|
+
this._core.open(this.name);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
disconnectedCallback() {
|
|
409
|
+
// Deliberately does NOT call unregisterAutoTrigger(). The autoTrigger click
|
|
410
|
+
// listener is a single process-wide document listener (registerAutoTrigger
|
|
411
|
+
// is idempotent), shared by every <wcs-broadcast> on the page — not owned by
|
|
412
|
+
// this element. Tearing it down when the last element disconnects would
|
|
413
|
+
// break a later-inserted trigger, so it is intentionally left installed for
|
|
414
|
+
// the document's lifetime (one passive listener, negligible cost). This
|
|
415
|
+
// mirrors <wcs-clipboard>, which registers but never unregisters either.
|
|
416
|
+
// unregisterAutoTrigger stays exported purely as a symmetric teardown hook
|
|
417
|
+
// for tests / advanced manual control; the production lifecycle never calls
|
|
418
|
+
// it.
|
|
419
|
+
this._core.dispose();
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function registerComponents() {
|
|
424
|
+
if (!customElements.get(config.tagNames.broadcast)) {
|
|
425
|
+
customElements.define(config.tagNames.broadcast, WcsBroadcast);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function bootstrapBroadcast(userConfig) {
|
|
430
|
+
if (userConfig) {
|
|
431
|
+
setConfig(userConfig);
|
|
432
|
+
}
|
|
433
|
+
registerComponents();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export { BroadcastCore, WcsBroadcast, bootstrapBroadcast, getConfig };
|
|
437
|
+
//# sourceMappingURL=index.esm.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.esm.js","sources":["../src/config.ts","../src/core/BroadcastCore.ts","../src/autoTrigger.ts","../src/components/Broadcast.ts","../src/registerComponents.ts","../src/bootstrapBroadcast.ts"],"sourcesContent":["import { IConfig, IWritableConfig } from \"./types.js\";\n\ninterface IInternalConfig extends IConfig {\n autoTrigger: boolean;\n triggerAttribute: string;\n tagNames: {\n broadcast: string;\n };\n}\n\nconst _config: IInternalConfig = {\n autoTrigger: true,\n triggerAttribute: \"data-broadcast-target\",\n tagNames: {\n broadcast: \"wcs-broadcast\",\n },\n};\n\nfunction deepFreeze<T>(obj: T): T {\n if (obj === null || typeof obj !== \"object\") return obj;\n Object.freeze(obj);\n for (const key of Object.keys(obj)) {\n deepFreeze((obj as Record<string, unknown>)[key]);\n }\n return obj;\n}\n\nfunction deepClone<T>(obj: T): T {\n if (obj === null || typeof obj !== \"object\") return obj;\n const clone: Record<string, unknown> = {};\n for (const key of Object.keys(obj)) {\n clone[key] = deepClone((obj as Record<string, unknown>)[key]);\n }\n return clone as T;\n}\n\nlet frozenConfig: IConfig | null = null;\n\n// Live reference to the mutable internal config: reads always reflect the latest\n// setConfig() call. The readonly IConfig type only blocks callers from writing\n// through it — the underlying object still changes. If you need a stable,\n// frozen snapshot that won't move under you, use getConfig() instead.\nexport const config: IConfig = _config as IConfig;\n\nexport function getConfig(): IConfig {\n if (!frozenConfig) {\n frozenConfig = deepFreeze(deepClone(_config));\n }\n return frozenConfig;\n}\n\nexport function setConfig(partialConfig: IWritableConfig): void {\n if (typeof partialConfig.autoTrigger === \"boolean\") {\n _config.autoTrigger = partialConfig.autoTrigger;\n }\n if (typeof partialConfig.triggerAttribute === \"string\") {\n _config.triggerAttribute = partialConfig.triggerAttribute;\n }\n if (partialConfig.tagNames) {\n Object.assign(_config.tagNames, partialConfig.tagNames);\n }\n frozenConfig = null;\n}\n","import { IWcBindable, WcsBroadcastErrorDetail } from \"../types.js\";\n\n/**\n * Headless cross-tab messaging primitive. A thin, framework-agnostic wrapper\n * around the BroadcastChannel API exposed through the wc-bindable protocol.\n *\n * BroadcastChannel is a same-origin pub/sub bus: every context (tab, iframe,\n * worker) that opens a channel with the same `name` receives every other\n * context's posts — but NOT its own. This self-exclusion is the whole point:\n * `post` is a `state → element` action (command-token) and an incoming\n * `message` is an `element → state` notification (event-token), but the two\n * only close the loop *across* a context boundary. Within a single tab a lone\n * `<wcs-broadcast>` never hears itself; open the page in two tabs to see the\n * round-trip.\n *\n * Unlike WebSocketCore there is no connection state, no reconnect, and no wire\n * encoding: a channel is \"open\" the moment it is constructed, and payloads ride\n * the browser's structured clone (objects pass through as-is, no JSON\n * round-trip). The only failure surfaces are a non-cloneable `post`\n * (`DataCloneError`), a `messageerror` (a peer posted something this context\n * cannot deserialize), and an absent `BroadcastChannel` constructor\n * (`unsupported`). All three flow through the `error` property — the Core never\n * throws — symmetrical with FetchCore / ClipboardCore.\n */\nexport class BroadcastCore extends EventTarget {\n static wcBindable: IWcBindable = {\n protocol: \"wc-bindable\",\n version: 1,\n properties: [\n { name: \"message\", event: \"wcs-broadcast:message\" },\n { name: \"error\", event: \"wcs-broadcast:error\" },\n ],\n commands: [\n { name: \"open\" },\n { name: \"post\" },\n { name: \"close\" },\n ],\n };\n\n private _target: EventTarget;\n private _channel: BroadcastChannel | null = null;\n private _name: string | null = null;\n private _message: any = null;\n private _error: WcsBroadcastErrorDetail | null = null;\n\n constructor(target?: EventTarget) {\n super();\n this._target = target ?? this;\n }\n\n get message(): any {\n return this._message;\n }\n\n get error(): WcsBroadcastErrorDetail | null {\n return this._error;\n }\n\n // --- State setters with event dispatch ---\n\n // Deliberately NO same-value guard (unlike `error` below). A received message\n // is an event, not idempotent state: a peer posting the same value twice is\n // two distinct occurrences and must re-fire wcs-broadcast:message each time so\n // a `message:` binding and any `eventToken.message:` subscriber see both.\n private _setMessage(message: any): void {\n this._message = message;\n this._target.dispatchEvent(new CustomEvent(\"wcs-broadcast:message\", {\n detail: message,\n bubbles: true,\n }));\n }\n\n private _setError(error: WcsBroadcastErrorDetail | null): void {\n // Same-value guard. `error` has no derived state, so suppressing redundant\n // null→null dispatches (e.g. a successful open clearing an already-null\n // error) avoids spurious events. Reference identity is sufficient: each\n // failure builds a fresh object, and the clear path always passes null.\n if (this._error === error) return;\n this._error = error;\n this._target.dispatchEvent(new CustomEvent(\"wcs-broadcast:error\", {\n detail: error,\n bubbles: true,\n }));\n }\n\n // --- Public API ---\n\n /**\n * Join the named channel. Any previously-open channel is closed first, so\n * calling `open()` again switches channels. When the BroadcastChannel\n * constructor is unavailable this surfaces an `unsupported` error and leaves\n * the Core channel-less (a later `post()` then errors loudly rather than\n * silently dropping).\n */\n open(name: string): void {\n if (!this._hasBroadcastChannel()) {\n this._setError(this._unsupportedError());\n return;\n }\n // Idempotent on the same channel: re-opening the channel we are already on\n // is pure churn (BroadcastChannel has no reconnect semantics). This also\n // absorbs the custom-element *upgrade* path — when a connected element with\n // a `name` attribute is upgraded (autoloader defines the tag after the\n // markup exists), the spec fires attributeChangedCallback (isConnected ===\n // true) *and* connectedCallback, so the Shell calls open() twice. Without\n // this guard that would create a channel and immediately tear it down.\n if (this._channel && this._name === name) return;\n this._closeChannel();\n this._setError(null);\n const channel = new BroadcastChannel(name);\n channel.addEventListener(\"message\", this._onMessage);\n channel.addEventListener(\"messageerror\", this._onMessageError);\n this._channel = channel;\n this._name = name;\n }\n\n /**\n * Post a structured-cloneable value to every other context on the channel.\n * The local context never receives it (self-exclusion). Never throws:\n * a non-cloneable value surfaces as a `DataCloneError` through `error`, and\n * posting with no open channel surfaces an `InvalidStateError`.\n */\n post(data: any): void {\n if (!this._hasBroadcastChannel()) {\n this._setError(this._unsupportedError());\n return;\n }\n if (!this._channel) {\n this._setError({\n name: \"InvalidStateError\",\n message: \"Channel is not open. Call open(name) before post().\",\n });\n return;\n }\n try {\n this._channel.postMessage(data);\n } catch (err) {\n this._setError(this._normalizeError(err));\n }\n }\n\n /** Leave the channel. Idempotent — a no-op when no channel is open. */\n close(): void {\n this._closeChannel();\n }\n\n /**\n * Tear the Core down for a disconnected Shell: close the channel and reset the\n * error shadow silently (no dispatch on a torn-down element). A later\n * reconnect re-opens via the Shell's connectedCallback.\n *\n * Asymmetry by design: `_message` is deliberately NOT reset. `error` is\n * transient connection state — a stale error from a previous channel would be\n * misleading after a reconnect, so it is cleared. `message` is the last value\n * received (an event payload), not connection state; it is retained as the\n * Core's last-known datum so a binding still reads it across a disconnect/\n * reconnect, and it is naturally overwritten by the next incoming message.\n */\n dispose(): void {\n this._closeChannel();\n this._error = null;\n }\n\n // --- Internal ---\n\n private _onMessage = (event: MessageEvent): void => {\n this._setMessage(event.data);\n };\n\n // Fired when a peer posted a value this context cannot deserialize. The event\n // carries no usable payload, so report a synthetic DataError.\n private _onMessageError = (): void => {\n this._setError({\n name: \"DataError\",\n message: \"Failed to deserialize a message received on the channel.\",\n });\n };\n\n private _closeChannel(): void {\n if (!this._channel) return;\n this._channel.removeEventListener(\"message\", this._onMessage);\n this._channel.removeEventListener(\"messageerror\", this._onMessageError);\n this._channel.close();\n this._channel = null;\n this._name = null;\n }\n\n private _hasBroadcastChannel(): boolean {\n return typeof BroadcastChannel !== \"undefined\";\n }\n\n private _normalizeError(err: unknown): WcsBroadcastErrorDetail {\n if (err instanceof Error) {\n // DOMException is an Error subclass; its `name` (DataCloneError, etc.) is\n // the meaningful discriminator for consumers switching on failure kind.\n return { name: err.name, message: err.message };\n }\n return { name: \"Error\", message: String(err) };\n }\n\n private _unsupportedError(): WcsBroadcastErrorDetail {\n return {\n name: \"NotSupportedError\",\n message: \"BroadcastChannel is not available in this environment.\",\n };\n }\n}\n","import { config } from \"./config.js\";\nimport type { WcsBroadcast } from \"./components/Broadcast.js\";\n\nlet registered = false;\n\n// Attribute names for the optional post-on-click DOM trigger (clipboard.js-style\n// DX). The element carrying `data-broadcast-target` points at a <wcs-broadcast>\n// by id; the text to post comes from either a literal `data-broadcast-text` or\n// a `data-broadcast-from` CSS selector resolving to a source element.\nconst TEXT_ATTRIBUTE = \"data-broadcast-text\";\nconst FROM_ATTRIBUTE = \"data-broadcast-from\";\n\nfunction resolveText(triggerElement: Element): string | null {\n // Literal text wins when present (including an empty string — posting \"\" is a\n // legitimate request). The `?? \"\"` right-hand side is defensive and\n // unreachable: hasAttribute() just returned true, so getAttribute() cannot be\n // null here. It exists only to satisfy the `string | null` return type — do\n // not chase coverage on it (the DOM contract makes the null branch impossible).\n if (triggerElement.hasAttribute(TEXT_ATTRIBUTE)) {\n return triggerElement.getAttribute(TEXT_ATTRIBUTE) ?? \"\";\n }\n const selector = triggerElement.getAttribute(FROM_ATTRIBUTE);\n if (!selector) return null;\n // A user-authored selector can be syntactically invalid (e.g. `[data-*` or a\n // bare `:not()`), which makes querySelector throw a SyntaxError. Swallow it\n // and treat the source as unresolvable — the same \"nothing to post\" path as a\n // selector that matches no element — so one bad attribute never crashes the\n // document-level click handler and kills autoTrigger for the whole tab.\n let source: Element | null;\n try {\n source = document.querySelector(selector);\n } catch {\n return null;\n }\n if (!source) return null;\n // Read a form control's `value`; fall back to text content. A bare\n // `\"value\" in source` check is too broad — it also matches <button>,\n // <li value>, <progress>, etc. (which carry an unrelated `value`), posting the\n // wrong thing. Narrow to the text-bearing controls a user actually points\n // `data-broadcast-from` at; everything else falls through to textContent.\n if (\n source instanceof HTMLInputElement ||\n source instanceof HTMLTextAreaElement ||\n source instanceof HTMLSelectElement\n ) {\n return source.value;\n }\n // `?? \"\"` is defensive: per the DOM spec only Document / DocumentType /\n // Notation nodes have a null `textContent`, and querySelector only ever\n // returns an Element (whose textContent is always a string). The branch is\n // therefore unreachable in practice and kept solely for the `string | null`\n // type — not worth a contrived test.\n return source.textContent ?? \"\";\n}\n\nfunction handleClick(event: Event): void {\n const target = event.target;\n if (!(target instanceof Element)) return;\n\n const triggerElement = target.closest<Element>(`[${config.triggerAttribute}]`);\n if (!triggerElement) return;\n\n const broadcastId = triggerElement.getAttribute(config.triggerAttribute);\n if (!broadcastId) return;\n\n // Resolve the registered constructor at call time instead of importing\n // WcsBroadcast as a value (avoids a components ⇄ autoTrigger import cycle:\n // Broadcast.connectedCallback() calls registerAutoTrigger()). instanceof\n // against the customElements registry keeps the same identity guarantee.\n const BroadcastCtor = customElements.get(config.tagNames.broadcast);\n const broadcastElement = document.getElementById(broadcastId);\n if (!BroadcastCtor || !(broadcastElement instanceof BroadcastCtor)) return;\n\n const text = resolveText(triggerElement);\n // No resolvable source: leave the click alone (do not preventDefault) so the\n // element's default action is unaffected.\n if (text === null) return;\n\n // Suppress the default action so a post can run without navigating. Intentional:\n // do not attach data-broadcast-target to an element whose default action you\n // also want (a real <a href> link). See README \"Optional DOM Triggering\".\n event.preventDefault();\n (broadcastElement as WcsBroadcast).post(text);\n}\n\nexport function registerAutoTrigger(): void {\n if (registered) return;\n registered = true;\n document.addEventListener(\"click\", handleClick);\n}\n\nexport function unregisterAutoTrigger(): void {\n if (!registered) return;\n registered = false;\n document.removeEventListener(\"click\", handleClick);\n}\n","import { config } from \"../config.js\";\nimport { IWcBindable, WcsBroadcastErrorDetail } from \"../types.js\";\nimport { BroadcastCore } from \"../core/BroadcastCore.js\";\nimport { registerAutoTrigger } from \"../autoTrigger.js\";\n\n// Named WcsBroadcast (not `Broadcast`) to match the <wcs-clipboard> WcsClipboard\n// / <wcs-ws> WcsWebSocket convention and avoid shadowing any global.\nexport class WcsBroadcast extends HTMLElement {\n // The channel opens synchronously in connectedCallback (no async init), so no\n // connectedCallbackPromise is needed — mirrors <wcs-ws>.\n static hasConnectedCallbackPromise = false;\n static wcBindable: IWcBindable = {\n ...BroadcastCore.wcBindable,\n // Shell-level settable surface. `name` selects the channel; `manual`\n // suppresses auto-open on connect. There is no momentary `post` property:\n // posting needs an argument (the payload), so element actions run via\n // command-token (`command.post: $command.ping`) or the DOM autoTrigger, not\n // a value-derived setter — keeping `post` a plain command keeps the\n // command-token wiring (`command.post:`) readable.\n inputs: [\n { name: \"name\", attribute: \"name\" },\n { name: \"manual\", attribute: \"manual\" },\n ],\n // Commands are identical to the Core's — no rename needed since the `name` /\n // `manual` attribute accessors do not collide with open/post/close.\n commands: BroadcastCore.wcBindable.commands,\n };\n static get observedAttributes(): string[] { return [\"name\"]; }\n\n private _core: BroadcastCore;\n\n constructor() {\n super();\n this._core = new BroadcastCore(this);\n }\n\n // --- Attribute accessors ---\n\n get name(): string {\n return this.getAttribute(\"name\") || \"\";\n }\n\n set name(value: string) {\n this.setAttribute(\"name\", value);\n }\n\n get manual(): boolean {\n return this.hasAttribute(\"manual\");\n }\n\n set manual(value: boolean) {\n if (value) {\n this.setAttribute(\"manual\", \"\");\n } else {\n this.removeAttribute(\"manual\");\n }\n }\n\n // --- Core delegated getters ---\n\n get message(): any {\n return this._core.message;\n }\n\n get error(): WcsBroadcastErrorDetail | null {\n return this._core.error;\n }\n\n // --- Commands ---\n\n open(): void {\n if (this.name) {\n this._core.open(this.name);\n }\n }\n\n post(data: any): void {\n this._core.post(data);\n }\n\n close(): void {\n this._core.close();\n }\n\n // --- Lifecycle ---\n\n attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null): void {\n if (name === \"name\" && this.isConnected && !this.manual && newValue) {\n this._core.open(newValue);\n }\n }\n\n connectedCallback(): void {\n this.style.display = \"none\";\n if (config.autoTrigger) {\n registerAutoTrigger();\n }\n if (!this.manual && this.name) {\n this._core.open(this.name);\n }\n }\n\n disconnectedCallback(): void {\n // Deliberately does NOT call unregisterAutoTrigger(). The autoTrigger click\n // listener is a single process-wide document listener (registerAutoTrigger\n // is idempotent), shared by every <wcs-broadcast> on the page — not owned by\n // this element. Tearing it down when the last element disconnects would\n // break a later-inserted trigger, so it is intentionally left installed for\n // the document's lifetime (one passive listener, negligible cost). This\n // mirrors <wcs-clipboard>, which registers but never unregisters either.\n // unregisterAutoTrigger stays exported purely as a symmetric teardown hook\n // for tests / advanced manual control; the production lifecycle never calls\n // it.\n this._core.dispose();\n }\n}\n","import { WcsBroadcast } from \"./components/Broadcast.js\";\nimport { config } from \"./config.js\";\n\nexport function registerComponents(): void {\n if (!customElements.get(config.tagNames.broadcast)) {\n customElements.define(config.tagNames.broadcast, WcsBroadcast);\n }\n}\n","import { setConfig } from \"./config.js\";\nimport { registerComponents } from \"./registerComponents.js\";\nimport { IWritableConfig } from \"./types.js\";\n\nexport function bootstrapBroadcast(userConfig?: IWritableConfig): void {\n if (userConfig) {\n setConfig(userConfig);\n }\n registerComponents();\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
const e={autoTrigger:!0,triggerAttribute:"data-broadcast-target",tagNames:{broadcast:"wcs-broadcast"}};function t(e){if(null===e||"object"!=typeof e)return e;Object.freeze(e);for(const s of Object.keys(e))t(e[s]);return e}function s(e){if(null===e||"object"!=typeof e)return e;const t={};for(const r of Object.keys(e))t[r]=s(e[r]);return t}let r=null;const n=e;function a(){return r||(r=t(s(e))),r}class o extends EventTarget{static wcBindable={protocol:"wc-bindable",version:1,properties:[{name:"message",event:"wcs-broadcast:message"},{name:"error",event:"wcs-broadcast:error"}],commands:[{name:"open"},{name:"post"},{name:"close"}]};_target;_channel=null;_name=null;_message=null;_error=null;constructor(e){super(),this._target=e??this}get message(){return this._message}get error(){return this._error}_setMessage(e){this._message=e,this._target.dispatchEvent(new CustomEvent("wcs-broadcast:message",{detail:e,bubbles:!0}))}_setError(e){this._error!==e&&(this._error=e,this._target.dispatchEvent(new CustomEvent("wcs-broadcast:error",{detail:e,bubbles:!0})))}open(e){if(!this._hasBroadcastChannel())return void this._setError(this._unsupportedError());if(this._channel&&this._name===e)return;this._closeChannel(),this._setError(null);const t=new BroadcastChannel(e);t.addEventListener("message",this._onMessage),t.addEventListener("messageerror",this._onMessageError),this._channel=t,this._name=e}post(e){if(this._hasBroadcastChannel())if(this._channel)try{this._channel.postMessage(e)}catch(e){this._setError(this._normalizeError(e))}else this._setError({name:"InvalidStateError",message:"Channel is not open. Call open(name) before post()."});else this._setError(this._unsupportedError())}close(){this._closeChannel()}dispose(){this._closeChannel(),this._error=null}_onMessage=e=>{this._setMessage(e.data)};_onMessageError=()=>{this._setError({name:"DataError",message:"Failed to deserialize a message received on the channel."})};_closeChannel(){this._channel&&(this._channel.removeEventListener("message",this._onMessage),this._channel.removeEventListener("messageerror",this._onMessageError),this._channel.close(),this._channel=null,this._name=null)}_hasBroadcastChannel(){return"undefined"!=typeof BroadcastChannel}_normalizeError(e){return e instanceof Error?{name:e.name,message:e.message}:{name:"Error",message:String(e)}}_unsupportedError(){return{name:"NotSupportedError",message:"BroadcastChannel is not available in this environment."}}}let i=!1;const c="data-broadcast-text";function l(e){const t=e.target;if(!(t instanceof Element))return;const s=t.closest(`[${n.triggerAttribute}]`);if(!s)return;const r=s.getAttribute(n.triggerAttribute);if(!r)return;const a=customElements.get(n.tagNames.broadcast),o=document.getElementById(r);if(!(a&&o instanceof a))return;const i=function(e){if(e.hasAttribute(c))return e.getAttribute(c)??"";const t=e.getAttribute("data-broadcast-from");if(!t)return null;let s;try{s=document.querySelector(t)}catch{return null}return s?s instanceof HTMLInputElement||s instanceof HTMLTextAreaElement||s instanceof HTMLSelectElement?s.value:s.textContent??"":null}(s);null!==i&&(e.preventDefault(),o.post(i))}class u extends HTMLElement{static hasConnectedCallbackPromise=!1;static wcBindable={...o.wcBindable,inputs:[{name:"name",attribute:"name"},{name:"manual",attribute:"manual"}],commands:o.wcBindable.commands};static get observedAttributes(){return["name"]}_core;constructor(){super(),this._core=new o(this)}get name(){return this.getAttribute("name")||""}set name(e){this.setAttribute("name",e)}get manual(){return this.hasAttribute("manual")}set manual(e){e?this.setAttribute("manual",""):this.removeAttribute("manual")}get message(){return this._core.message}get error(){return this._core.error}open(){this.name&&this._core.open(this.name)}post(e){this._core.post(e)}close(){this._core.close()}attributeChangedCallback(e,t,s){"name"===e&&this.isConnected&&!this.manual&&s&&this._core.open(s)}connectedCallback(){this.style.display="none",n.autoTrigger&&(i||(i=!0,document.addEventListener("click",l))),!this.manual&&this.name&&this._core.open(this.name)}disconnectedCallback(){this._core.dispose()}}function h(t){var s;t&&("boolean"==typeof(s=t).autoTrigger&&(e.autoTrigger=s.autoTrigger),"string"==typeof s.triggerAttribute&&(e.triggerAttribute=s.triggerAttribute),s.tagNames&&Object.assign(e.tagNames,s.tagNames),r=null),customElements.get(n.tagNames.broadcast)||customElements.define(n.tagNames.broadcast,u)}export{o as BroadcastCore,u as WcsBroadcast,h as bootstrapBroadcast,a as getConfig};
|
|
2
|
+
//# sourceMappingURL=index.esm.min.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.esm.min.js","sources":["../src/config.ts","../src/core/BroadcastCore.ts","../src/autoTrigger.ts","../src/components/Broadcast.ts","../src/bootstrapBroadcast.ts","../src/registerComponents.ts"],"sourcesContent":["import { IConfig, IWritableConfig } from \"./types.js\";\n\ninterface IInternalConfig extends IConfig {\n autoTrigger: boolean;\n triggerAttribute: string;\n tagNames: {\n broadcast: string;\n };\n}\n\nconst _config: IInternalConfig = {\n autoTrigger: true,\n triggerAttribute: \"data-broadcast-target\",\n tagNames: {\n broadcast: \"wcs-broadcast\",\n },\n};\n\nfunction deepFreeze<T>(obj: T): T {\n if (obj === null || typeof obj !== \"object\") return obj;\n Object.freeze(obj);\n for (const key of Object.keys(obj)) {\n deepFreeze((obj as Record<string, unknown>)[key]);\n }\n return obj;\n}\n\nfunction deepClone<T>(obj: T): T {\n if (obj === null || typeof obj !== \"object\") return obj;\n const clone: Record<string, unknown> = {};\n for (const key of Object.keys(obj)) {\n clone[key] = deepClone((obj as Record<string, unknown>)[key]);\n }\n return clone as T;\n}\n\nlet frozenConfig: IConfig | null = null;\n\n// Live reference to the mutable internal config: reads always reflect the latest\n// setConfig() call. The readonly IConfig type only blocks callers from writing\n// through it — the underlying object still changes. If you need a stable,\n// frozen snapshot that won't move under you, use getConfig() instead.\nexport const config: IConfig = _config as IConfig;\n\nexport function getConfig(): IConfig {\n if (!frozenConfig) {\n frozenConfig = deepFreeze(deepClone(_config));\n }\n return frozenConfig;\n}\n\nexport function setConfig(partialConfig: IWritableConfig): void {\n if (typeof partialConfig.autoTrigger === \"boolean\") {\n _config.autoTrigger = partialConfig.autoTrigger;\n }\n if (typeof partialConfig.triggerAttribute === \"string\") {\n _config.triggerAttribute = partialConfig.triggerAttribute;\n }\n if (partialConfig.tagNames) {\n Object.assign(_config.tagNames, partialConfig.tagNames);\n }\n frozenConfig = null;\n}\n","import { IWcBindable, WcsBroadcastErrorDetail } from \"../types.js\";\n\n/**\n * Headless cross-tab messaging primitive. A thin, framework-agnostic wrapper\n * around the BroadcastChannel API exposed through the wc-bindable protocol.\n *\n * BroadcastChannel is a same-origin pub/sub bus: every context (tab, iframe,\n * worker) that opens a channel with the same `name` receives every other\n * context's posts — but NOT its own. This self-exclusion is the whole point:\n * `post` is a `state → element` action (command-token) and an incoming\n * `message` is an `element → state` notification (event-token), but the two\n * only close the loop *across* a context boundary. Within a single tab a lone\n * `<wcs-broadcast>` never hears itself; open the page in two tabs to see the\n * round-trip.\n *\n * Unlike WebSocketCore there is no connection state, no reconnect, and no wire\n * encoding: a channel is \"open\" the moment it is constructed, and payloads ride\n * the browser's structured clone (objects pass through as-is, no JSON\n * round-trip). The only failure surfaces are a non-cloneable `post`\n * (`DataCloneError`), a `messageerror` (a peer posted something this context\n * cannot deserialize), and an absent `BroadcastChannel` constructor\n * (`unsupported`). All three flow through the `error` property — the Core never\n * throws — symmetrical with FetchCore / ClipboardCore.\n */\nexport class BroadcastCore extends EventTarget {\n static wcBindable: IWcBindable = {\n protocol: \"wc-bindable\",\n version: 1,\n properties: [\n { name: \"message\", event: \"wcs-broadcast:message\" },\n { name: \"error\", event: \"wcs-broadcast:error\" },\n ],\n commands: [\n { name: \"open\" },\n { name: \"post\" },\n { name: \"close\" },\n ],\n };\n\n private _target: EventTarget;\n private _channel: BroadcastChannel | null = null;\n private _name: string | null = null;\n private _message: any = null;\n private _error: WcsBroadcastErrorDetail | null = null;\n\n constructor(target?: EventTarget) {\n super();\n this._target = target ?? this;\n }\n\n get message(): any {\n return this._message;\n }\n\n get error(): WcsBroadcastErrorDetail | null {\n return this._error;\n }\n\n // --- State setters with event dispatch ---\n\n // Deliberately NO same-value guard (unlike `error` below). A received message\n // is an event, not idempotent state: a peer posting the same value twice is\n // two distinct occurrences and must re-fire wcs-broadcast:message each time so\n // a `message:` binding and any `eventToken.message:` subscriber see both.\n private _setMessage(message: any): void {\n this._message = message;\n this._target.dispatchEvent(new CustomEvent(\"wcs-broadcast:message\", {\n detail: message,\n bubbles: true,\n }));\n }\n\n private _setError(error: WcsBroadcastErrorDetail | null): void {\n // Same-value guard. `error` has no derived state, so suppressing redundant\n // null→null dispatches (e.g. a successful open clearing an already-null\n // error) avoids spurious events. Reference identity is sufficient: each\n // failure builds a fresh object, and the clear path always passes null.\n if (this._error === error) return;\n this._error = error;\n this._target.dispatchEvent(new CustomEvent(\"wcs-broadcast:error\", {\n detail: error,\n bubbles: true,\n }));\n }\n\n // --- Public API ---\n\n /**\n * Join the named channel. Any previously-open channel is closed first, so\n * calling `open()` again switches channels. When the BroadcastChannel\n * constructor is unavailable this surfaces an `unsupported` error and leaves\n * the Core channel-less (a later `post()` then errors loudly rather than\n * silently dropping).\n */\n open(name: string): void {\n if (!this._hasBroadcastChannel()) {\n this._setError(this._unsupportedError());\n return;\n }\n // Idempotent on the same channel: re-opening the channel we are already on\n // is pure churn (BroadcastChannel has no reconnect semantics). This also\n // absorbs the custom-element *upgrade* path — when a connected element with\n // a `name` attribute is upgraded (autoloader defines the tag after the\n // markup exists), the spec fires attributeChangedCallback (isConnected ===\n // true) *and* connectedCallback, so the Shell calls open() twice. Without\n // this guard that would create a channel and immediately tear it down.\n if (this._channel && this._name === name) return;\n this._closeChannel();\n this._setError(null);\n const channel = new BroadcastChannel(name);\n channel.addEventListener(\"message\", this._onMessage);\n channel.addEventListener(\"messageerror\", this._onMessageError);\n this._channel = channel;\n this._name = name;\n }\n\n /**\n * Post a structured-cloneable value to every other context on the channel.\n * The local context never receives it (self-exclusion). Never throws:\n * a non-cloneable value surfaces as a `DataCloneError` through `error`, and\n * posting with no open channel surfaces an `InvalidStateError`.\n */\n post(data: any): void {\n if (!this._hasBroadcastChannel()) {\n this._setError(this._unsupportedError());\n return;\n }\n if (!this._channel) {\n this._setError({\n name: \"InvalidStateError\",\n message: \"Channel is not open. Call open(name) before post().\",\n });\n return;\n }\n try {\n this._channel.postMessage(data);\n } catch (err) {\n this._setError(this._normalizeError(err));\n }\n }\n\n /** Leave the channel. Idempotent — a no-op when no channel is open. */\n close(): void {\n this._closeChannel();\n }\n\n /**\n * Tear the Core down for a disconnected Shell: close the channel and reset the\n * error shadow silently (no dispatch on a torn-down element). A later\n * reconnect re-opens via the Shell's connectedCallback.\n *\n * Asymmetry by design: `_message` is deliberately NOT reset. `error` is\n * transient connection state — a stale error from a previous channel would be\n * misleading after a reconnect, so it is cleared. `message` is the last value\n * received (an event payload), not connection state; it is retained as the\n * Core's last-known datum so a binding still reads it across a disconnect/\n * reconnect, and it is naturally overwritten by the next incoming message.\n */\n dispose(): void {\n this._closeChannel();\n this._error = null;\n }\n\n // --- Internal ---\n\n private _onMessage = (event: MessageEvent): void => {\n this._setMessage(event.data);\n };\n\n // Fired when a peer posted a value this context cannot deserialize. The event\n // carries no usable payload, so report a synthetic DataError.\n private _onMessageError = (): void => {\n this._setError({\n name: \"DataError\",\n message: \"Failed to deserialize a message received on the channel.\",\n });\n };\n\n private _closeChannel(): void {\n if (!this._channel) return;\n this._channel.removeEventListener(\"message\", this._onMessage);\n this._channel.removeEventListener(\"messageerror\", this._onMessageError);\n this._channel.close();\n this._channel = null;\n this._name = null;\n }\n\n private _hasBroadcastChannel(): boolean {\n return typeof BroadcastChannel !== \"undefined\";\n }\n\n private _normalizeError(err: unknown): WcsBroadcastErrorDetail {\n if (err instanceof Error) {\n // DOMException is an Error subclass; its `name` (DataCloneError, etc.) is\n // the meaningful discriminator for consumers switching on failure kind.\n return { name: err.name, message: err.message };\n }\n return { name: \"Error\", message: String(err) };\n }\n\n private _unsupportedError(): WcsBroadcastErrorDetail {\n return {\n name: \"NotSupportedError\",\n message: \"BroadcastChannel is not available in this environment.\",\n };\n }\n}\n","import { config } from \"./config.js\";\nimport type { WcsBroadcast } from \"./components/Broadcast.js\";\n\nlet registered = false;\n\n// Attribute names for the optional post-on-click DOM trigger (clipboard.js-style\n// DX). The element carrying `data-broadcast-target` points at a <wcs-broadcast>\n// by id; the text to post comes from either a literal `data-broadcast-text` or\n// a `data-broadcast-from` CSS selector resolving to a source element.\nconst TEXT_ATTRIBUTE = \"data-broadcast-text\";\nconst FROM_ATTRIBUTE = \"data-broadcast-from\";\n\nfunction resolveText(triggerElement: Element): string | null {\n // Literal text wins when present (including an empty string — posting \"\" is a\n // legitimate request). The `?? \"\"` right-hand side is defensive and\n // unreachable: hasAttribute() just returned true, so getAttribute() cannot be\n // null here. It exists only to satisfy the `string | null` return type — do\n // not chase coverage on it (the DOM contract makes the null branch impossible).\n if (triggerElement.hasAttribute(TEXT_ATTRIBUTE)) {\n return triggerElement.getAttribute(TEXT_ATTRIBUTE) ?? \"\";\n }\n const selector = triggerElement.getAttribute(FROM_ATTRIBUTE);\n if (!selector) return null;\n // A user-authored selector can be syntactically invalid (e.g. `[data-*` or a\n // bare `:not()`), which makes querySelector throw a SyntaxError. Swallow it\n // and treat the source as unresolvable — the same \"nothing to post\" path as a\n // selector that matches no element — so one bad attribute never crashes the\n // document-level click handler and kills autoTrigger for the whole tab.\n let source: Element | null;\n try {\n source = document.querySelector(selector);\n } catch {\n return null;\n }\n if (!source) return null;\n // Read a form control's `value`; fall back to text content. A bare\n // `\"value\" in source` check is too broad — it also matches <button>,\n // <li value>, <progress>, etc. (which carry an unrelated `value`), posting the\n // wrong thing. Narrow to the text-bearing controls a user actually points\n // `data-broadcast-from` at; everything else falls through to textContent.\n if (\n source instanceof HTMLInputElement ||\n source instanceof HTMLTextAreaElement ||\n source instanceof HTMLSelectElement\n ) {\n return source.value;\n }\n // `?? \"\"` is defensive: per the DOM spec only Document / DocumentType /\n // Notation nodes have a null `textContent`, and querySelector only ever\n // returns an Element (whose textContent is always a string). The branch is\n // therefore unreachable in practice and kept solely for the `string | null`\n // type — not worth a contrived test.\n return source.textContent ?? \"\";\n}\n\nfunction handleClick(event: Event): void {\n const target = event.target;\n if (!(target instanceof Element)) return;\n\n const triggerElement = target.closest<Element>(`[${config.triggerAttribute}]`);\n if (!triggerElement) return;\n\n const broadcastId = triggerElement.getAttribute(config.triggerAttribute);\n if (!broadcastId) return;\n\n // Resolve the registered constructor at call time instead of importing\n // WcsBroadcast as a value (avoids a components ⇄ autoTrigger import cycle:\n // Broadcast.connectedCallback() calls registerAutoTrigger()). instanceof\n // against the customElements registry keeps the same identity guarantee.\n const BroadcastCtor = customElements.get(config.tagNames.broadcast);\n const broadcastElement = document.getElementById(broadcastId);\n if (!BroadcastCtor || !(broadcastElement instanceof BroadcastCtor)) return;\n\n const text = resolveText(triggerElement);\n // No resolvable source: leave the click alone (do not preventDefault) so the\n // element's default action is unaffected.\n if (text === null) return;\n\n // Suppress the default action so a post can run without navigating. Intentional:\n // do not attach data-broadcast-target to an element whose default action you\n // also want (a real <a href> link). See README \"Optional DOM Triggering\".\n event.preventDefault();\n (broadcastElement as WcsBroadcast).post(text);\n}\n\nexport function registerAutoTrigger(): void {\n if (registered) return;\n registered = true;\n document.addEventListener(\"click\", handleClick);\n}\n\nexport function unregisterAutoTrigger(): void {\n if (!registered) return;\n registered = false;\n document.removeEventListener(\"click\", handleClick);\n}\n","import { config } from \"../config.js\";\nimport { IWcBindable, WcsBroadcastErrorDetail } from \"../types.js\";\nimport { BroadcastCore } from \"../core/BroadcastCore.js\";\nimport { registerAutoTrigger } from \"../autoTrigger.js\";\n\n// Named WcsBroadcast (not `Broadcast`) to match the <wcs-clipboard> WcsClipboard\n// / <wcs-ws> WcsWebSocket convention and avoid shadowing any global.\nexport class WcsBroadcast extends HTMLElement {\n // The channel opens synchronously in connectedCallback (no async init), so no\n // connectedCallbackPromise is needed — mirrors <wcs-ws>.\n static hasConnectedCallbackPromise = false;\n static wcBindable: IWcBindable = {\n ...BroadcastCore.wcBindable,\n // Shell-level settable surface. `name` selects the channel; `manual`\n // suppresses auto-open on connect. There is no momentary `post` property:\n // posting needs an argument (the payload), so element actions run via\n // command-token (`command.post: $command.ping`) or the DOM autoTrigger, not\n // a value-derived setter — keeping `post` a plain command keeps the\n // command-token wiring (`command.post:`) readable.\n inputs: [\n { name: \"name\", attribute: \"name\" },\n { name: \"manual\", attribute: \"manual\" },\n ],\n // Commands are identical to the Core's — no rename needed since the `name` /\n // `manual` attribute accessors do not collide with open/post/close.\n commands: BroadcastCore.wcBindable.commands,\n };\n static get observedAttributes(): string[] { return [\"name\"]; }\n\n private _core: BroadcastCore;\n\n constructor() {\n super();\n this._core = new BroadcastCore(this);\n }\n\n // --- Attribute accessors ---\n\n get name(): string {\n return this.getAttribute(\"name\") || \"\";\n }\n\n set name(value: string) {\n this.setAttribute(\"name\", value);\n }\n\n get manual(): boolean {\n return this.hasAttribute(\"manual\");\n }\n\n set manual(value: boolean) {\n if (value) {\n this.setAttribute(\"manual\", \"\");\n } else {\n this.removeAttribute(\"manual\");\n }\n }\n\n // --- Core delegated getters ---\n\n get message(): any {\n return this._core.message;\n }\n\n get error(): WcsBroadcastErrorDetail | null {\n return this._core.error;\n }\n\n // --- Commands ---\n\n open(): void {\n if (this.name) {\n this._core.open(this.name);\n }\n }\n\n post(data: any): void {\n this._core.post(data);\n }\n\n close(): void {\n this._core.close();\n }\n\n // --- Lifecycle ---\n\n attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null): void {\n if (name === \"name\" && this.isConnected && !this.manual && newValue) {\n this._core.open(newValue);\n }\n }\n\n connectedCallback(): void {\n this.style.display = \"none\";\n if (config.autoTrigger) {\n registerAutoTrigger();\n }\n if (!this.manual && this.name) {\n this._core.open(this.name);\n }\n }\n\n disconnectedCallback(): void {\n // Deliberately does NOT call unregisterAutoTrigger(). The autoTrigger click\n // listener is a single process-wide document listener (registerAutoTrigger\n // is idempotent), shared by every <wcs-broadcast> on the page — not owned by\n // this element. Tearing it down when the last element disconnects would\n // break a later-inserted trigger, so it is intentionally left installed for\n // the document's lifetime (one passive listener, negligible cost). This\n // mirrors <wcs-clipboard>, which registers but never unregisters either.\n // unregisterAutoTrigger stays exported purely as a symmetric teardown hook\n // for tests / advanced manual control; the production lifecycle never calls\n // it.\n this._core.dispose();\n }\n}\n","import { setConfig } from \"./config.js\";\nimport { registerComponents } from \"./registerComponents.js\";\nimport { IWritableConfig } from \"./types.js\";\n\nexport function bootstrapBroadcast(userConfig?: IWritableConfig): void {\n if (userConfig) {\n setConfig(userConfig);\n }\n registerComponents();\n}\n","import { WcsBroadcast } from \"./components/Broadcast.js\";\nimport { config } from \"./config.js\";\n\nexport function registerComponents(): void {\n if (!customElements.get(config.tagNames.broadcast)) {\n customElements.define(config.tagNames.broadcast, WcsBroadcast);\n }\n}\n"],"names":["_config","autoTrigger","triggerAttribute","tagNames","broadcast","deepFreeze","obj","Object","freeze","key","keys","deepClone","clone","frozenConfig","config","getConfig","BroadcastCore","EventTarget","static","protocol","version","properties","name","event","commands","_target","_channel","_name","_message","_error","constructor","target","super","this","message","error","_setMessage","dispatchEvent","CustomEvent","detail","bubbles","_setError","open","_hasBroadcastChannel","_unsupportedError","_closeChannel","channel","BroadcastChannel","addEventListener","_onMessage","_onMessageError","post","data","postMessage","err","_normalizeError","close","dispose","removeEventListener","Error","String","registered","TEXT_ATTRIBUTE","handleClick","Element","triggerElement","closest","broadcastId","getAttribute","BroadcastCtor","customElements","get","broadcastElement","document","getElementById","text","hasAttribute","selector","source","querySelector","HTMLInputElement","HTMLTextAreaElement","HTMLSelectElement","value","textContent","resolveText","preventDefault","WcsBroadcast","HTMLElement","wcBindable","inputs","attribute","observedAttributes","_core","setAttribute","manual","removeAttribute","attributeChangedCallback","_oldValue","newValue","isConnected","connectedCallback","style","display","disconnectedCallback","bootstrapBroadcast","userConfig","partialConfig","assign","define"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wcstack/broadcast",
|
|
3
|
+
"version": "1.12.0",
|
|
4
|
+
"description": "Declarative cross-tab messaging component for Web Components. Framework-agnostic BroadcastChannel primitive via wc-bindable-protocol.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.esm.js",
|
|
7
|
+
"module": "./dist/index.esm.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.esm.js"
|
|
13
|
+
},
|
|
14
|
+
"./auto": "./dist/auto.min.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"clean": "rimraf dist .tsc-out",
|
|
21
|
+
"build": "rimraf dist .tsc-out && tsc && rollup -c",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"test:watch": "vitest",
|
|
24
|
+
"test:coverage": "vitest run --coverage",
|
|
25
|
+
"lint": "eslint src",
|
|
26
|
+
"version:patch": "npm version patch",
|
|
27
|
+
"version:minor": "npm version minor",
|
|
28
|
+
"version:major": "npm version major",
|
|
29
|
+
"prepublishOnly": "npm run build && npm run test:coverage"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"web-components",
|
|
33
|
+
"broadcast",
|
|
34
|
+
"broadcastchannel",
|
|
35
|
+
"cross-tab",
|
|
36
|
+
"pub-sub",
|
|
37
|
+
"custom-elements",
|
|
38
|
+
"wc-bindable",
|
|
39
|
+
"declarative",
|
|
40
|
+
"zero-dependencies",
|
|
41
|
+
"framework-agnostic"
|
|
42
|
+
],
|
|
43
|
+
"author": "mogera551",
|
|
44
|
+
"homepage": "https://wcstack.github.io",
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "https://github.com/wcstack/wcstack.git",
|
|
48
|
+
"directory": "packages/broadcast"
|
|
49
|
+
},
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/wcstack/wcstack/issues"
|
|
52
|
+
},
|
|
53
|
+
"license": "MIT",
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@eslint/js": "^9.39.1",
|
|
56
|
+
"@rollup/plugin-terser": "^0.4.4",
|
|
57
|
+
"@rollup/plugin-typescript": "^11.1.6",
|
|
58
|
+
"@vitest/coverage-v8": "^4.0.15",
|
|
59
|
+
"@vitest/ui": "^4.0.15",
|
|
60
|
+
"eslint": "^9.39.1",
|
|
61
|
+
"globals": "^16.5.0",
|
|
62
|
+
"happy-dom": "^20.0.11",
|
|
63
|
+
"rimraf": "^6.0.1",
|
|
64
|
+
"rollup": "^4.22.4",
|
|
65
|
+
"rollup-plugin-dts": "^6.1.1",
|
|
66
|
+
"rollup-plugin-copy": "^3.5.0",
|
|
67
|
+
"tslib": "^2.8.1",
|
|
68
|
+
"typescript": "^5.9.3",
|
|
69
|
+
"typescript-eslint": "^8.49.0",
|
|
70
|
+
"vitest": "^4.0.15"
|
|
71
|
+
}
|
|
72
|
+
}
|