@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 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.