@hedwigjs/broker 0.1.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/LICENSE +21 -0
- package/README.md +670 -0
- package/dist/index.cjs +2238 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.mts +1507 -0
- package/dist/index.d.ts +1507 -0
- package/dist/index.mjs +2227 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +78 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hedwig contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
# @hedwigjs/broker
|
|
2
|
+
|
|
3
|
+
Runtime broker for the Hedwig messaging toolkit. One typed API for
|
|
4
|
+
event and request messages across modules — in-process, cross-frame,
|
|
5
|
+
cross-tab, and cross-process — with hooks and observability built in.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# not yet published to npm — inside the Hedwig monorepo:
|
|
9
|
+
npm install @hedwigjs/broker
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
> Pre-release (`0.1.0`, private). The public surface documented here is
|
|
13
|
+
> stable across the pre-release; anything marked *internal* may change.
|
|
14
|
+
|
|
15
|
+
**Full project docs & reference stand →** [`../..#readme`](../..#readme)
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Table of contents
|
|
20
|
+
|
|
21
|
+
- [Quickstart](#quickstart)
|
|
22
|
+
- [Core concepts](#core-concepts)
|
|
23
|
+
- [API reference](#api-reference)
|
|
24
|
+
- [Broker facade](#broker-facade)
|
|
25
|
+
- [Client](#client)
|
|
26
|
+
- [Broker extension surface](#broker-extension-surface)
|
|
27
|
+
- [Message shape](#message-shape)
|
|
28
|
+
- [RoutingResult](#routingresult)
|
|
29
|
+
- [Built-in bridges](#built-in-bridges)
|
|
30
|
+
- [Custom transports](#custom-transports)
|
|
31
|
+
- [Hooks](#hooks)
|
|
32
|
+
- [Message history & replay](#message-history--replay)
|
|
33
|
+
- [Backpressure](#backpressure)
|
|
34
|
+
- [System events](#system-events)
|
|
35
|
+
- [Inspector](#inspector)
|
|
36
|
+
- [Recipes](#recipes)
|
|
37
|
+
- [Performance](#performance)
|
|
38
|
+
- [TypeScript — bring your own contracts](#typescript--bring-your-own-contracts)
|
|
39
|
+
- [License](#license)
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Quickstart
|
|
44
|
+
|
|
45
|
+
Boot the broker once in the host, create a typed client per module,
|
|
46
|
+
subscribe and emit.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { initBroker, createClient } from '@hedwigjs/broker';
|
|
50
|
+
|
|
51
|
+
type Topic = 'cart.item-added.v1' | 'cart.get-total.v1';
|
|
52
|
+
type TopicPayloads = {
|
|
53
|
+
'cart.item-added.v1': { sku: string; qty: number };
|
|
54
|
+
'cart.get-total.v1': void;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// 1. Host bootstrap — once, in the shell.
|
|
58
|
+
initBroker<Topic, TopicPayloads>({
|
|
59
|
+
history: { enabled: true, maxSize: 200 },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// 2. Per-module client — typed.
|
|
63
|
+
const cartClient = createClient<Topic, TopicPayloads>('cart');
|
|
64
|
+
|
|
65
|
+
// 3. Fire-and-forget event.
|
|
66
|
+
cartClient.on('cart.item-added.v1', (msg) => {
|
|
67
|
+
console.log('added', msg.data.sku, '×', msg.data.qty);
|
|
68
|
+
});
|
|
69
|
+
void cartClient.emit('cart.item-added.v1', { sku: 'CROISSANT', qty: 2 });
|
|
70
|
+
|
|
71
|
+
// 4. Typed request → response.
|
|
72
|
+
cartClient.on('cart.get-total.v1', () => 12.5); // handler returns
|
|
73
|
+
const analyticsClient = createClient<Topic, TopicPayloads>('analytics');
|
|
74
|
+
const result = await analyticsClient.request<'cart.get-total.v1', number>(
|
|
75
|
+
'cart',
|
|
76
|
+
'cart.get-total.v1',
|
|
77
|
+
undefined,
|
|
78
|
+
);
|
|
79
|
+
// result.status === 'ACK', result.data === 12.5
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Topic strings, payload shapes, and request responses are all inferred
|
|
83
|
+
from the two type parameters. Rename a topic in one place — TypeScript
|
|
84
|
+
lights up every subscriber and emitter that has drifted.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Core concepts
|
|
89
|
+
|
|
90
|
+
- **Message** — one typed unit that travels through the broker: a
|
|
91
|
+
`topic` + `data` payload + routing metadata (`source`, `target`,
|
|
92
|
+
`id`, `timestamp`).
|
|
93
|
+
- **Topic** — a versioned string like `'cart.item-added.v1'`. Every
|
|
94
|
+
topic maps to exactly one payload type in the `TopicPayloads` map.
|
|
95
|
+
- **Module (client)** — any participant in the communication graph.
|
|
96
|
+
Each module obtains its own `Client` from `createClient(id)` and uses
|
|
97
|
+
it for the full lifecycle of that module.
|
|
98
|
+
- **Broker** — the singleton runtime returned by `initBroker(config)`.
|
|
99
|
+
Owns the routing plane, the hook chain, the history buffer, and the
|
|
100
|
+
bridges. In-process by default; bridges extend it across contexts.
|
|
101
|
+
- **Bridge** — a lane that forwards matching topics to a `BridgeTransport`
|
|
102
|
+
(postMessage, WebSocket, BroadcastChannel, …) and injects inbound
|
|
103
|
+
traffic back into the same pipeline as `fromExternal: true`.
|
|
104
|
+
- **Two semantics** — `emit()` for fan-out events, `request()` for a
|
|
105
|
+
targeted call awaiting a typed response. Retention and replay are an
|
|
106
|
+
orthogonal mechanism layered on top of both, not a third semantic.
|
|
107
|
+
|
|
108
|
+
The broker is **contract-first**: topics and payloads are described in
|
|
109
|
+
TypeScript, and the runtime is a thin executor over that contract.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## API reference
|
|
114
|
+
|
|
115
|
+
### Broker facade
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import {
|
|
119
|
+
initBroker,
|
|
120
|
+
getBroker,
|
|
121
|
+
createClient,
|
|
122
|
+
destroyBroker,
|
|
123
|
+
} from '@hedwigjs/broker';
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
| Function | Purpose |
|
|
127
|
+
| --------------------------------------- | --------------------------------------------------------------------------------------------- |
|
|
128
|
+
| `initBroker<T, P>(config?)` | Boot the broker once. Idempotent — returns the existing instance if already initialized. |
|
|
129
|
+
| `getBroker<T, P>()` | Return the current broker without holding the `initBroker` reference. Throws if not booted. |
|
|
130
|
+
| `createClient<T, P>(id)` | Return the typed `Client` for `id`. Idempotent: existing clients are reset and returned. |
|
|
131
|
+
| `destroyBroker()` | Tear down bridges, subscriptions, history, hooks, and the client registry. |
|
|
132
|
+
|
|
133
|
+
`BrokerConfig`:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
{
|
|
137
|
+
history?: {
|
|
138
|
+
enabled: boolean;
|
|
139
|
+
maxSize?: number; // default 1000
|
|
140
|
+
ttl?: number; // ms, undefined = no expiration
|
|
141
|
+
};
|
|
142
|
+
logger?: BrokerLogger; // see "Logger" below
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Client
|
|
147
|
+
|
|
148
|
+
Returned by `createClient(id)`.
|
|
149
|
+
|
|
150
|
+
| Method | Semantics |
|
|
151
|
+
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
152
|
+
| `on(topic, handler, options?)` | Subscribe. Returns an unsubscribe function. Accepts `backpressure` and `replay` options. Throws if an `onSubscribe` hook rejects. |
|
|
153
|
+
| `off(topic)` | Unsubscribe. No-op if not subscribed. |
|
|
154
|
+
| `emit(topic, data, options?)` | Broadcast to every subscriber of `topic`. Resolves with the aggregated `RoutingResult`. |
|
|
155
|
+
| `request<K, R>(recipient, topic, data, options?)` | Targeted call to one recipient. Resolves with `RoutingResult<R>` where `R` is the handler's return type. |
|
|
156
|
+
| `reset()` | Drop every subscription for this client; keep it registered. Used internally for HMR / re-mount. |
|
|
157
|
+
| `destroy()` | Unregister the client; the instance becomes inert. |
|
|
158
|
+
| `id` | The client id passed to `createClient`. |
|
|
159
|
+
|
|
160
|
+
Handlers receive the full immutable `Message<T, P[T]>` — the payload
|
|
161
|
+
lives on `msg.data`. A handler's return value is captured on
|
|
162
|
+
`RoutingResult.data` for `request()` callers. Sync and async handlers
|
|
163
|
+
are both supported.
|
|
164
|
+
|
|
165
|
+
### Broker extension surface
|
|
166
|
+
|
|
167
|
+
Returned by `initBroker()` / `getBroker()`.
|
|
168
|
+
|
|
169
|
+
| Member | Kind | Purpose |
|
|
170
|
+
| ------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------ |
|
|
171
|
+
| `$systemEvents` | push channel | Subscribe to broker lifecycle events (clients, subscriptions, bridges, rejections). |
|
|
172
|
+
| `inspect` | pull snapshot | Read-only view over clients, subscriptions, bridges, history. |
|
|
173
|
+
| `$debug.send(source, topic, target, data)` | internal | Inject a synthetic message through the full pipeline. Marked `synthetic: true`. For DevTools & tests. |
|
|
174
|
+
| `addBridge(id, { transport, forward })` | wiring | Register a bridge. Idempotent — an existing id is destroyed and replaced. Returns a remover. |
|
|
175
|
+
| `useBeforeSendHook(fn)` | extension | Gate outgoing messages. Return `{ allowed: false, message }` to reject. |
|
|
176
|
+
| `useAfterSendHook(fn)` | extension | Observe delivery outcomes. Receives the frozen message + `RoutingResult`. |
|
|
177
|
+
| `useOnSubscribeHook(fn)` | extension | Gate subscriptions. Return `{ allowed: false, message }` to reject. |
|
|
178
|
+
| `destroy()` | lifecycle | Full shutdown. Bridges torn down, registries cleared, subsequent calls become no-op warnings. |
|
|
179
|
+
|
|
180
|
+
The `$` prefix marks broker-internal surfaces intended for tooling
|
|
181
|
+
(DevTools, tracing) — never for business code.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Message shape
|
|
186
|
+
|
|
187
|
+
Every routed message has the same envelope:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
interface Message<T extends string, P> {
|
|
191
|
+
id: string; // unique per message ("abc-42")
|
|
192
|
+
topic: T; // e.g. 'cart.item-added.v1'
|
|
193
|
+
source: string; // client id that emitted
|
|
194
|
+
target: string; // recipient id, or '*' for broadcast
|
|
195
|
+
data: P; // typed payload
|
|
196
|
+
timestamp: number; // Date.now() at emit
|
|
197
|
+
replayed?: boolean; // true when delivered from the history buffer
|
|
198
|
+
fromExternal?: boolean; // true when injected by a bridge
|
|
199
|
+
synthetic?: boolean; // true when injected via broker.$debug.send
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Messages are `Object.freeze()`d before entering the pipeline. Do not
|
|
204
|
+
mutate `msg.data`; treat handlers as pure observers.
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## RoutingResult
|
|
209
|
+
|
|
210
|
+
Every `emit` / `request` resolves with a `RoutingResult`:
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
{
|
|
214
|
+
status: 'ACK' | 'NACK';
|
|
215
|
+
reason: RoutingReasonType;
|
|
216
|
+
message: string; // human-readable
|
|
217
|
+
timestamp: number;
|
|
218
|
+
recipientId?: ClientID; // unicast (request)
|
|
219
|
+
recipientIds?: ClientID[]; // multicast (emit)
|
|
220
|
+
data?: TResponse; // handler return value (request only)
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`RoutingReason` values:
|
|
225
|
+
|
|
226
|
+
| Reason | Meaning |
|
|
227
|
+
| ------------------- | -------------------------------------------------------------- |
|
|
228
|
+
| `DELIVERED` | Unicast delivered; handler ran to completion. |
|
|
229
|
+
| `DISPATCHED` | Multicast dispatched to at least one subscriber. |
|
|
230
|
+
| `REPLAY_DELIVERED` | Message came from the history buffer (replay). |
|
|
231
|
+
| `HOOK_REJECTED` | A `beforeSend` hook returned `{ allowed: false }`. |
|
|
232
|
+
| `NO_SUBSCRIBERS` | Multicast — no one is subscribed to this topic. |
|
|
233
|
+
| `NOT_SUBSCRIBED` | Unicast — target exists but has no handler for this topic. |
|
|
234
|
+
| `HANDLER_FAILED` | The handler threw; the error is logged and the promise resolves NACK. |
|
|
235
|
+
| `BROKER_DESTROYED` | Emit called on a destroyed broker. |
|
|
236
|
+
|
|
237
|
+
The full enum is exported as `RoutingReason` for exhaustive `switch`
|
|
238
|
+
statements.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Built-in bridges
|
|
243
|
+
|
|
244
|
+
Four transports ship inside `@hedwigjs/broker` for the common wires.
|
|
245
|
+
Each implements `BridgeTransport`; pair with `broker.addBridge()`.
|
|
246
|
+
|
|
247
|
+
| Transport | Wire | Direction | Notes |
|
|
248
|
+
| ------------------------------- | ------------------------------------------ | --------- | ------------------------------------------------------------------------------------------------- |
|
|
249
|
+
| `PostMessageTransport` | `window.postMessage` between window/iframe | duplex | `allowedOrigins` allowlist is the trust boundary. Warn on `'*'` origin. |
|
|
250
|
+
| `BroadcastChannelTransport` | `BroadcastChannel` between same-origin tabs | duplex | Same-origin only. Sync UI state across tabs (theme, session, locale). |
|
|
251
|
+
| `WebSocketTransport` | Wraps an externally-owned `WebSocket` | duplex | Connection/reconnect handled outside the transport. Serializes as JSON. |
|
|
252
|
+
| `SSETransport` | Wraps `EventSource` | inbound | `send()` is a no-op with a warning. Browser handles reconnect. Pair a POST endpoint if you need upstream. |
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
import { getBroker, PostMessageTransport } from '@hedwigjs/broker';
|
|
256
|
+
|
|
257
|
+
const iframe = document.querySelector('iframe')!;
|
|
258
|
+
getBroker().addBridge('checkout-iframe', {
|
|
259
|
+
transport: new PostMessageTransport({
|
|
260
|
+
target: iframe.contentWindow!,
|
|
261
|
+
allowedOrigins: ['https://checkout.example.com'],
|
|
262
|
+
}),
|
|
263
|
+
forward: ['cart.*', 'user.*'],
|
|
264
|
+
});
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
`forward` patterns support `*` glob segments. Anything not matched
|
|
268
|
+
stays local to the current broker instance.
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## Custom transports
|
|
273
|
+
|
|
274
|
+
`BridgeTransport` is the extension point. Anything that satisfies its
|
|
275
|
+
three-method contract plugs in — WebRTC data channels, Service Worker
|
|
276
|
+
messaging, Electron IPC, MessageChannel to a Worker, custom protocols.
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
import type { BridgeTransport } from '@hedwigjs/broker';
|
|
280
|
+
|
|
281
|
+
class MyTransport implements BridgeTransport {
|
|
282
|
+
#cb: ((data: unknown) => void) | null = null;
|
|
283
|
+
|
|
284
|
+
send(data: unknown): void {
|
|
285
|
+
try { myWire.publish(data); }
|
|
286
|
+
catch (e) { console.error('[MyTransport] send failed:', e); }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
onMessage(cb: (data: unknown) => void): () => void {
|
|
290
|
+
this.#cb = cb;
|
|
291
|
+
const off = myWire.subscribe((payload) => {
|
|
292
|
+
// Validate source/origin/signature BEFORE forwarding.
|
|
293
|
+
// The transport is the trust boundary between broker and wire.
|
|
294
|
+
if (!isTrusted(payload)) return;
|
|
295
|
+
this.#cb?.(payload);
|
|
296
|
+
});
|
|
297
|
+
return () => { off(); this.#cb = null; };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
destroy(): void { this.#cb = null; myWire.close(); }
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Contract summary:
|
|
305
|
+
|
|
306
|
+
- **`send(data)`** must not throw — catch wire errors and log. A failing
|
|
307
|
+
wire must not break the broker pipeline.
|
|
308
|
+
- **`onMessage(cb)`** is called once at bridge construction. Validate
|
|
309
|
+
every inbound payload (origin, signature, schema) before invoking
|
|
310
|
+
`cb`. Return an unsubscribe function.
|
|
311
|
+
- **`destroy()`** releases sockets, listeners, timers. Must be
|
|
312
|
+
idempotent.
|
|
313
|
+
|
|
314
|
+
Transports are the **trust boundary** between the broker and the
|
|
315
|
+
outside world. The broker will route whatever a transport hands it —
|
|
316
|
+
validate at the wire.
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## Hooks
|
|
321
|
+
|
|
322
|
+
Three hooks let adapters and plugins extend the broker without
|
|
323
|
+
touching internals. Register on the broker, receive an unregister
|
|
324
|
+
function.
|
|
325
|
+
|
|
326
|
+
### `useBeforeSendHook` — gate outgoing messages
|
|
327
|
+
|
|
328
|
+
Synchronous. Runs for every emit *and* for every inbound bridge
|
|
329
|
+
message (use `msg.fromExternal` to distinguish). Return
|
|
330
|
+
`{ allowed: false, message }` to short-circuit; the emit resolves with
|
|
331
|
+
`NACK HOOK_REJECTED` and a `message.rejected` system event fires.
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
import { getBroker } from '@hedwigjs/broker';
|
|
335
|
+
|
|
336
|
+
getBroker().useBeforeSendHook((msg) => {
|
|
337
|
+
if (msg.topic.startsWith('admin.') && msg.source !== 'shell') {
|
|
338
|
+
return { allowed: false, message: 'admin.* is shell-only' };
|
|
339
|
+
}
|
|
340
|
+
return { allowed: true };
|
|
341
|
+
});
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
Typical uses: ACL / capability checks, schema validation, tracing
|
|
345
|
+
span injection, redaction.
|
|
346
|
+
|
|
347
|
+
### `useAfterSendHook` — observe outcomes
|
|
348
|
+
|
|
349
|
+
Fire-and-forget. Receives the frozen message and the final
|
|
350
|
+
`RoutingResult`. Exceptions are caught and logged.
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
getBroker().useAfterSendHook((msg, result) => {
|
|
354
|
+
metrics.record(msg.topic, {
|
|
355
|
+
ok: result.status === 'ACK',
|
|
356
|
+
reason: result.reason,
|
|
357
|
+
recipients: result.recipientIds?.length ?? (result.recipientId ? 1 : 0),
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
Typical uses: metrics, structured logs, tracing exit, audit trail.
|
|
363
|
+
|
|
364
|
+
### `useOnSubscribeHook` — gate subscriptions
|
|
365
|
+
|
|
366
|
+
Synchronous. Called before a subscription is registered. Return
|
|
367
|
+
`{ allowed: false, message }` to reject — `client.on()` throws with
|
|
368
|
+
that message and a `subscription.rejected` system event fires.
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
getBroker().useOnSubscribeHook((topic, clientId) => {
|
|
372
|
+
if (topic.startsWith('user.pii.') && !isTrustedClient(clientId)) {
|
|
373
|
+
return { allowed: false, message: `${clientId} may not read PII` };
|
|
374
|
+
}
|
|
375
|
+
return { allowed: true };
|
|
376
|
+
});
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Typical uses: role-based ACL on read paths, dev-time contract audits.
|
|
380
|
+
|
|
381
|
+
---
|
|
382
|
+
|
|
383
|
+
## Message history & replay
|
|
384
|
+
|
|
385
|
+
The broker keeps an in-memory ring buffer. Enable it once in
|
|
386
|
+
`initBroker`, opt in per-message on `emit`, and opt in per-subscription
|
|
387
|
+
on `on`.
|
|
388
|
+
|
|
389
|
+
```ts
|
|
390
|
+
initBroker({
|
|
391
|
+
history: { enabled: true, maxSize: 500, ttl: 60_000 }, // 1 min TTL
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// Producer opts a message in.
|
|
395
|
+
void cartClient.emit(
|
|
396
|
+
'cart.snapshot.v1',
|
|
397
|
+
{ items, total },
|
|
398
|
+
{ history: true },
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
// Late subscriber replays the most recent 10 snapshots.
|
|
402
|
+
menuClient.on(
|
|
403
|
+
'cart.snapshot.v1',
|
|
404
|
+
(msg) => renderCart(msg.data),
|
|
405
|
+
{ replay: { limit: 10 } },
|
|
406
|
+
);
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Replayed messages carry `replayed: true` — handlers can tell historical
|
|
410
|
+
traffic apart from live traffic. Replay is best-effort against a
|
|
411
|
+
bounded buffer; do not rely on it as durable storage.
|
|
412
|
+
|
|
413
|
+
**When to use.** Late-joining modules (an MFE that mounts after the
|
|
414
|
+
initial burst), UI resurrection (a modal that re-opens should see the
|
|
415
|
+
latest `state.v1` message), reconnection recovery.
|
|
416
|
+
|
|
417
|
+
**When NOT to use.** As an event log — the buffer is bounded and
|
|
418
|
+
in-memory. As a request/response mechanism — use `request()` for that.
|
|
419
|
+
|
|
420
|
+
---
|
|
421
|
+
|
|
422
|
+
## Backpressure
|
|
423
|
+
|
|
424
|
+
Per-subscription control over handler invocation rate. Three
|
|
425
|
+
strategies, mutually exclusive:
|
|
426
|
+
|
|
427
|
+
```ts
|
|
428
|
+
menuClient.on(
|
|
429
|
+
'inventory.tick.v1',
|
|
430
|
+
updateStock,
|
|
431
|
+
{ backpressure: { throttle: 100 } }, // ≤ 10 calls/sec
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
searchClient.on(
|
|
435
|
+
'search.query.v1',
|
|
436
|
+
runSearch,
|
|
437
|
+
{ backpressure: { debounce: 250 } }, // fire after 250 ms of quiet
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
telemetryClient.on(
|
|
441
|
+
'metrics.event.v1',
|
|
442
|
+
ingest,
|
|
443
|
+
{
|
|
444
|
+
backpressure: {
|
|
445
|
+
rateLimit: { max: 1000, window: 1000 }, // ≤ 1k/sec, drop excess
|
|
446
|
+
onDrop: (n) => log.warn(`dropped ${n} metrics events`),
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
);
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
| Strategy | Behavior | Fit |
|
|
453
|
+
| ------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------- |
|
|
454
|
+
| `throttle` | First call runs immediately; subsequent calls collapse into a trailing invocation each window. | Real-time charts, high-frequency progress |
|
|
455
|
+
| `debounce` | Every call resets a timer; only the last message runs after `debounce` ms of silence. | Search-as-you-type, form validation |
|
|
456
|
+
| `rateLimit` | Allow `max` messages per `window` ms. Excess is dropped (lost). `onDrop` reports counts. | Ingest protection, burst tolerance |
|
|
457
|
+
|
|
458
|
+
The wrapper is on the hot path even when idle — bench
|
|
459
|
+
`07-backpressure-overhead` measures the per-call cost so you can
|
|
460
|
+
budget accordingly.
|
|
461
|
+
|
|
462
|
+
---
|
|
463
|
+
|
|
464
|
+
## System events
|
|
465
|
+
|
|
466
|
+
Broker-lifecycle signals published on `broker.$systemEvents`. Not
|
|
467
|
+
user messages — infrastructure telemetry.
|
|
468
|
+
|
|
469
|
+
| Event | Payload | Fired when |
|
|
470
|
+
| ------------------------ | ---------------------------------------------------------- | -------------------------------------------------------------------------- |
|
|
471
|
+
| `client.registered` | `{ clientId, at }` | `createClient(id)` registers a new id. |
|
|
472
|
+
| `client.unregistered` | `{ clientId, at }` | `client.destroy()` or broker teardown. |
|
|
473
|
+
| `subscription.added` | `{ clientId, topic, options? }` | `client.on(topic, …)` succeeds. |
|
|
474
|
+
| `subscription.removed` | `{ clientId, topic }` | `client.off(topic)` or reset/destroy. |
|
|
475
|
+
| `subscription.rejected` | `{ clientId, topic, reason }` | An `onSubscribe` hook denied the subscription. Client also throws. |
|
|
476
|
+
| `message.rejected` | `{ source, target, topic, reason }` | A `beforeSend` hook denied a message. Emit also resolves `NACK HOOK_REJECTED`. |
|
|
477
|
+
| `bridge.added` | `{ bridgeId }` | `broker.addBridge(id, …)`. |
|
|
478
|
+
| `bridge.removed` | `{ bridgeId }` | Bridge remover called, or broker teardown. |
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
const off = getBroker().$systemEvents.on('message.rejected', (evt) => {
|
|
482
|
+
console.warn('blocked by ACL:', evt);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// Or subscribe to everything for a unified feed (DevTools style):
|
|
486
|
+
const offAll = getBroker().$systemEvents.onAny((event, payload) => {
|
|
487
|
+
ring.push({ event, payload, at: Date.now() });
|
|
488
|
+
});
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
Listeners are fire-and-forget; exceptions are caught and logged, never
|
|
492
|
+
propagated back into the pipeline.
|
|
493
|
+
|
|
494
|
+
---
|
|
495
|
+
|
|
496
|
+
## Inspector
|
|
497
|
+
|
|
498
|
+
Point-in-time state snapshots on `broker.inspect`. Pair with
|
|
499
|
+
`$systemEvents` to build accurate initial state without races:
|
|
500
|
+
snapshot first, then subscribe.
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
const inspect = getBroker().inspect;
|
|
504
|
+
|
|
505
|
+
inspect.getClients(); // [{ id, connectedAt, subscriptions: [...] }, ...]
|
|
506
|
+
inspect.getSubscribedClientIds(); // ['cart', 'menu', ...]
|
|
507
|
+
inspect.getBridges(); // [{ id, forwardPatterns, transportKind }, ...]
|
|
508
|
+
inspect.getHistory(); // [{ message, timestamp, sequence }, ...]
|
|
509
|
+
inspect.getHistoryStats(); // { enabled, count, oldestTimestamp?, newestTimestamp?, memoryUsage? }
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
All array returns are `ReadonlyArray` — mutating them will not affect
|
|
513
|
+
broker state. This is the API `@hedwigjs/devtools` reads on the pull
|
|
514
|
+
path.
|
|
515
|
+
|
|
516
|
+
---
|
|
517
|
+
|
|
518
|
+
## Recipes
|
|
519
|
+
|
|
520
|
+
### Idiomatic module setup
|
|
521
|
+
|
|
522
|
+
```ts
|
|
523
|
+
// modules/cart/src/client.ts
|
|
524
|
+
import { createClient } from '@hedwigjs/broker';
|
|
525
|
+
import type { Topic, TopicPayloads } from '@your-app/registry';
|
|
526
|
+
|
|
527
|
+
export const cartClient = createClient<Topic, TopicPayloads>('cart');
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
One client per module. Import it wherever the module needs to talk to
|
|
531
|
+
others. `createClient` is idempotent, so HMR and re-mounts are safe.
|
|
532
|
+
|
|
533
|
+
### Point-to-point request with a typed response
|
|
534
|
+
|
|
535
|
+
```ts
|
|
536
|
+
const result = await checkoutClient.request<'cart.get-total.v1', number>(
|
|
537
|
+
'cart',
|
|
538
|
+
'cart.get-total.v1',
|
|
539
|
+
undefined,
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
if (result.status === 'ACK') {
|
|
543
|
+
proceed(result.data); // typed as number
|
|
544
|
+
}
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
The recipient's handler simply returns a value:
|
|
548
|
+
|
|
549
|
+
```ts
|
|
550
|
+
cartClient.on('cart.get-total.v1', () => computeTotal());
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
### Late-joining subscriber gets last state
|
|
554
|
+
|
|
555
|
+
```ts
|
|
556
|
+
// Producer:
|
|
557
|
+
void cartClient.emit('cart.snapshot.v1', snapshot, { history: true });
|
|
558
|
+
|
|
559
|
+
// Late subscriber gets the most recent snapshot immediately:
|
|
560
|
+
cartClient.on(
|
|
561
|
+
'cart.snapshot.v1',
|
|
562
|
+
render,
|
|
563
|
+
{ replay: { limit: 1 } },
|
|
564
|
+
);
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
### Cross-tab sync
|
|
568
|
+
|
|
569
|
+
```ts
|
|
570
|
+
import { getBroker, BroadcastChannelTransport } from '@hedwigjs/broker';
|
|
571
|
+
|
|
572
|
+
getBroker().addBridge('cross-tab', {
|
|
573
|
+
transport: new BroadcastChannelTransport('my-app'),
|
|
574
|
+
forward: ['theme.*', 'user.session.*'],
|
|
575
|
+
});
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
Now any `emit` on those topics reaches every open tab of the same
|
|
579
|
+
origin. On the receiving side the same handler runs, with
|
|
580
|
+
`msg.fromExternal === true`.
|
|
581
|
+
|
|
582
|
+
### Declarative allowlist ACL
|
|
583
|
+
|
|
584
|
+
```ts
|
|
585
|
+
const ALLOW: Record<string, string[]> = {
|
|
586
|
+
shell: ['*'],
|
|
587
|
+
cart: ['cart.*'],
|
|
588
|
+
menu: ['menu.*', 'cart.get-total.v1'],
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
getBroker().useBeforeSendHook((msg) => {
|
|
592
|
+
const patterns = ALLOW[msg.source] ?? [];
|
|
593
|
+
const ok = patterns.some((p) => matchPattern(msg.topic, p));
|
|
594
|
+
return ok
|
|
595
|
+
? { allowed: true }
|
|
596
|
+
: { allowed: false, message: `${msg.source} may not emit ${msg.topic}` };
|
|
597
|
+
});
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
Every rejection surfaces as `message.rejected` on `$systemEvents` —
|
|
601
|
+
route it to your audit sink for a security signal.
|
|
602
|
+
|
|
603
|
+
### Pluggable logger
|
|
604
|
+
|
|
605
|
+
```ts
|
|
606
|
+
initBroker({
|
|
607
|
+
logger: {
|
|
608
|
+
warn: (event, meta) => log.warn({ event, ...meta }),
|
|
609
|
+
error: (event, meta) => Sentry.captureMessage(event, { extra: meta }),
|
|
610
|
+
},
|
|
611
|
+
});
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
`BrokerLogEvent` is a closed union of stable string codes
|
|
615
|
+
(`'handler.failed'`, `'hook.failed'`, …) — safe to use as filter keys
|
|
616
|
+
in Sentry / Datadog / Grafana.
|
|
617
|
+
|
|
618
|
+
---
|
|
619
|
+
|
|
620
|
+
## Performance
|
|
621
|
+
|
|
622
|
+
`@hedwigjs/broker` ships a 15-scenario tinybench harness. Highlights
|
|
623
|
+
from the reference machine (macOS, M-series):
|
|
624
|
+
|
|
625
|
+
- `emit` throughput at 10 subscribers — millions of ops/sec.
|
|
626
|
+
- Dispatch cost stays constant per-subscriber as fan-out grows to
|
|
627
|
+
10 000 (`04-fanout-scaling`).
|
|
628
|
+
- Dispatch stays O(1) across 10 000 unrelated topics
|
|
629
|
+
(`08-multi-topic-isolation`).
|
|
630
|
+
- Backpressure wrapper adds tens of nanoseconds per call
|
|
631
|
+
(`07-backpressure-overhead`).
|
|
632
|
+
|
|
633
|
+
```bash
|
|
634
|
+
npm run bench # every scenario, sequentially
|
|
635
|
+
npm run bench:one 04 # one scenario, matched by prefix
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
Full method and scenario list: [`benchmarks/README.md`](./benchmarks/README.md).
|
|
639
|
+
|
|
640
|
+
---
|
|
641
|
+
|
|
642
|
+
## TypeScript — bring your own contracts
|
|
643
|
+
|
|
644
|
+
The broker consumes two type parameters — `Topic` (the string union)
|
|
645
|
+
and `TopicPayloads` (the `topic → payload` map) — from anywhere. The
|
|
646
|
+
runtime does not care where they come from.
|
|
647
|
+
|
|
648
|
+
```ts
|
|
649
|
+
// Hand-written
|
|
650
|
+
type Topic = 'user.login.v1' | 'cart.item-added.v1';
|
|
651
|
+
type TopicPayloads = {
|
|
652
|
+
'user.login.v1': { userId: string };
|
|
653
|
+
'cart.item-added.v1': { sku: string; qty: number };
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
// Or generated from Zod schemas, Protobuf, GraphQL codegen, OpenAPI,
|
|
657
|
+
// or the opinionated starter kit `@hedwigjs/create-registry`.
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
The `@hedwigjs/create-registry` CLI scaffolds a topic registry package
|
|
661
|
+
for TS-first greenfield projects, but nothing forces it — mix
|
|
662
|
+
generated and hand-written topics in one map if that suits your
|
|
663
|
+
codebase. See
|
|
664
|
+
[`../../docs/content/guides/bring-your-own-contracts.md`](../../docs/content/guides/bring-your-own-contracts.md).
|
|
665
|
+
|
|
666
|
+
---
|
|
667
|
+
|
|
668
|
+
## License
|
|
669
|
+
|
|
670
|
+
MIT.
|