tabus-js 0.1.1 → 0.2.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.md CHANGED
@@ -1,9 +1,9 @@
1
+ # tabus-js
2
+
1
3
  <div align="center">
2
- <img src="./public/tabus-logo.png" alt="tabus" width="200" />
4
+ <img src="./public/tabus-logo.png" alt="tabus-js" width="250" />
3
5
  </div>
4
6
 
5
- # tabus
6
-
7
7
  > Type-safe cross-tab message bus for the browser, built on the native `BroadcastChannel` API.
8
8
 
9
9
  [![npm](https://img.shields.io/npm/v/tabus-js)](https://www.npmjs.com/package/tabus-js)
@@ -13,7 +13,7 @@
13
13
  ## Why
14
14
 
15
15
  When a user signs out in one tab, other open tabs keep showing sensitive data.
16
- `tabus` solves this by letting tabs broadcast events to each other instantly — no server, no WebSockets, no polling.
16
+ `tabus-js` solves this by letting tabs broadcast events to each other instantly — no server, no WebSockets, no polling.
17
17
 
18
18
  ## Install
19
19
 
@@ -23,6 +23,23 @@ npm install tabus-js
23
23
  pnpm add tabus-js
24
24
  ```
25
25
 
26
+ ## Framework compatibility
27
+
28
+ `tabus-js` is framework-agnostic. It works in any environment that runs JavaScript in the browser.
29
+
30
+ | Environment | Supported |
31
+ | --------------------- | ----------- |
32
+ | Vanilla JS | ✅ |
33
+ | React | ✅ |
34
+ | Vue | ✅ |
35
+ | Angular | ✅ |
36
+ | Svelte | ✅ |
37
+ | Next.js (client only) | ✅ |
38
+ | Nuxt (client only) | ✅ |
39
+ | Node.js / SSR | ⚠️ fallback |
40
+
41
+ > **SSR note:** `BroadcastChannel` is a browser API — it does not exist on the server. In SSR environments (Next.js, Nuxt, SvelteKit), create the `Tabus` instance only on the client side. If `BroadcastChannel` is unavailable, `tabus-js` falls back to an in-memory bus automatically and emits a `console.warn`.
42
+
26
43
  ## Quick start
27
44
 
28
45
  ```ts
@@ -48,6 +65,171 @@ bus.emit("logout", { userId: 42 });
48
65
  bus.destroy();
49
66
  ```
50
67
 
68
+ ## Examples
69
+
70
+ ### Vanilla JS
71
+
72
+ ```js
73
+ import { Tabus } from "tabus-js";
74
+
75
+ const bus = new Tabus("my-app");
76
+
77
+ bus.on("notification", ({ message }) => {
78
+ alert(message);
79
+ });
80
+
81
+ document.querySelector("#btn").addEventListener("click", () => {
82
+ bus.emit("notification", { message: "Hello from this tab!" });
83
+ });
84
+ ```
85
+
86
+ ### React
87
+
88
+ ```tsx
89
+ import { useEffect } from "react";
90
+ import { Tabus } from "tabus-js";
91
+
92
+ type AppEvents = {
93
+ logout: { userId: number };
94
+ };
95
+
96
+ const bus = new Tabus<AppEvents>("my-app");
97
+
98
+ function App() {
99
+ useEffect(() => {
100
+ const handler = ({ userId }: { userId: number }) => {
101
+ console.log("Logged out:", userId);
102
+ redirectToLogin();
103
+ };
104
+
105
+ bus.on("logout", handler);
106
+ return () => bus.off("logout", handler);
107
+ }, []);
108
+
109
+ const handleLogout = () => {
110
+ bus.emit("logout", { userId: 42 });
111
+ };
112
+
113
+ return <button onClick={handleLogout}>Logout</button>;
114
+ }
115
+ ```
116
+
117
+ ### Vue
118
+
119
+ ```vue
120
+ <script setup lang="ts">
121
+ import { onMounted, onUnmounted } from "vue";
122
+ import { Tabus } from "tabus-js";
123
+
124
+ type AppEvents = {
125
+ logout: { userId: number };
126
+ };
127
+
128
+ const bus = new Tabus<AppEvents>("my-app");
129
+
130
+ const handler = ({ userId }: { userId: number }) => {
131
+ console.log("Logged out:", userId);
132
+ redirectToLogin();
133
+ };
134
+
135
+ onMounted(() => bus.on("logout", handler));
136
+ onUnmounted(() => bus.off("logout", handler));
137
+
138
+ const handleLogout = () => bus.emit("logout", { userId: 42 });
139
+ </script>
140
+
141
+ <template>
142
+ <button @click="handleLogout">Logout</button>
143
+ </template>
144
+ ```
145
+
146
+ ### Next.js (client only)
147
+
148
+ ```tsx
149
+ "use client";
150
+
151
+ import { useEffect } from "react";
152
+ import { Tabus } from "tabus-js";
153
+
154
+ type AppEvents = {
155
+ logout: { userId: number };
156
+ };
157
+
158
+ // Create outside the component to share across renders
159
+ const bus = new Tabus<AppEvents>("my-app");
160
+
161
+ export function LogoutButton() {
162
+ useEffect(() => {
163
+ const handler = ({ userId }: { userId: number }) => {
164
+ redirectToLogin();
165
+ };
166
+
167
+ bus.on("logout", handler);
168
+ return () => bus.off("logout", handler);
169
+ }, []);
170
+
171
+ return (
172
+ <button onClick={() => bus.emit("logout", { userId: 42 })}>Logout</button>
173
+ );
174
+ }
175
+ ```
176
+
177
+ ### Real-world: sync logout across tabs
178
+
179
+ ```ts
180
+ import { Tabus } from "tabus-js";
181
+
182
+ type AuthEvents = {
183
+ logout: { userId: number };
184
+ sessionExpired: { reason: string };
185
+ };
186
+
187
+ const auth = new Tabus<AuthEvents>("auth");
188
+
189
+ // In your auth service
190
+ auth.on("logout", () => {
191
+ clearLocalStorage();
192
+ redirectToLogin();
193
+ });
194
+
195
+ auth.on("sessionExpired", ({ reason }) => {
196
+ showToast(`Session expired: ${reason}`);
197
+ redirectToLogin();
198
+ });
199
+
200
+ // When the user clicks logout
201
+ function logout(userId: number) {
202
+ auth.emit("logout", { userId });
203
+ }
204
+ ```
205
+
206
+ ### Real-world: sync cart across tabs
207
+
208
+ ```ts
209
+ import { Tabus } from "tabus-js";
210
+
211
+ type CartEvents = {
212
+ itemAdded: { productId: string; qty: number };
213
+ itemRemoved: { productId: string };
214
+ cleared: Record<string, never>;
215
+ };
216
+
217
+ const cart = new Tabus<CartEvents>("cart");
218
+
219
+ cart.on("itemAdded", ({ productId, qty }) => {
220
+ updateCartUI(productId, qty);
221
+ });
222
+
223
+ cart.on("cleared", () => {
224
+ resetCartUI();
225
+ });
226
+
227
+ // When user adds an item
228
+ function addToCart(productId: string, qty: number) {
229
+ cart.emit("itemAdded", { productId, qty });
230
+ }
231
+ ```
232
+
51
233
  ## API
52
234
 
53
235
  ### `new Tabus<Events>(channelName?)`
@@ -77,6 +259,29 @@ Closes the channel, emits `tab:leave`, and removes all handlers. Safe to call mu
77
259
 
78
260
  A unique UUID identifying this tab instance. Read-only.
79
261
 
262
+ ## Throttle
263
+
264
+ Limit how often messages are sent to the channel. Useful for high-frequency
265
+ events like `mousemove`, `scroll`, or real-time input sync.
266
+
267
+ ```ts
268
+ // Allow at most one message every 16ms (~60fps)
269
+ const bus = new Tabus('canvas', { throttle: 16 })
270
+
271
+ window.addEventListener('mousemove', (e) => {
272
+ bus.emit('cursor:moved', { x: e.clientX, y: e.clientY })
273
+ // Without throttle: 60 messages/sec per tab
274
+ // With throttle: 1 message every 16ms, rest discarded
275
+ })
276
+ ```
277
+
278
+ Without throttle (default), every `emit()` call sends immediately.
279
+ With throttle, calls that arrive faster than `throttleMs` are silently discarded.
280
+
281
+ | Option | Type | Default | Description |
282
+ |--------|------|---------|-------------|
283
+ | `throttle` | `number` | `0` | Minimum ms between emitted messages. `0` = no throttle. |
284
+
80
285
  ## Lifecycle events
81
286
 
82
287
  ```ts
@@ -88,7 +293,7 @@ These are emitted automatically — you cannot emit them manually.
88
293
 
89
294
  ## Fallback
90
295
 
91
- If `BroadcastChannel` is not available (old browsers, some WebViews), `tabus` falls back to an in-memory bus automatically. Events will still work within the same tab. A `console.warn` is emitted once per channel to notify you.
296
+ If `BroadcastChannel` is not available (old browsers, some WebViews, SSR), `tabus-js` falls back to an in-memory bus automatically. Events will still work within the same tab. A `console.warn` is emitted once per channel to notify you.
92
297
 
93
298
  ## Browser support
94
299
 
package/dist/index.d.mts CHANGED
@@ -1,4 +1,17 @@
1
1
  type Handler<T = unknown> = (payload: T) => void;
2
+ interface TabusOptions {
3
+ /**
4
+ * Minimum time in milliseconds between emitted messages.
5
+ * When set, emit() calls that arrive faster than this interval
6
+ * are discarded. Useful for high-frequency events like mousemove
7
+ * or scroll to prevent flooding the main thread.
8
+ *
9
+ * @example
10
+ * // Allow at most one message every 16ms (~60fps)
11
+ * const bus = new Tabus('canvas', { throttle: 16 })
12
+ */
13
+ throttle?: number;
14
+ }
2
15
  type EventMap = Record<string, unknown>;
3
16
  interface TabusMessage<T = unknown> {
4
17
  tabId: string;
@@ -32,6 +45,8 @@ declare class Tabus<Events extends EventMap = EventMap> {
32
45
  private readonly localHandlers;
33
46
  private destroyed;
34
47
  private joinTimer;
48
+ private readonly throttleMs;
49
+ private lastEmitAt;
35
50
  /**
36
51
  * Creates a new Tabus instance on the given channel.
37
52
  *
@@ -43,6 +58,8 @@ declare class Tabus<Events extends EventMap = EventMap> {
43
58
  *
44
59
  * @param channelName - Shared channel name (default: `"tabus"`). All
45
60
  * instances with the same channel name can communicate with each other.
61
+ * @param options - Configuration options for this instance.
62
+ * @param options.throttle - Minimum ms between emitted messages (default: 0, no throttle).
46
63
  *
47
64
  * @example
48
65
  * ```ts
@@ -51,7 +68,7 @@ declare class Tabus<Events extends EventMap = EventMap> {
51
68
  * bus.on("tab:join", ({ tabId }) => console.log("peer joined:", tabId));
52
69
  * ```
53
70
  */
54
- constructor(channelName?: string);
71
+ constructor(channelName?: string, options?: TabusOptions);
55
72
  /**
56
73
  * Subscribes to an event, including internal lifecycle events
57
74
  * `"tab:join"` and `"tab:leave"`.
@@ -136,4 +153,4 @@ interface ITransport {
136
153
  destroy(): void;
137
154
  }
138
155
 
139
- export { type EventMap, type Handler, type ITransport, type InternalEvents, Tabus, type TabusMessage };
156
+ export { type EventMap, type Handler, type ITransport, type InternalEvents, Tabus, type TabusMessage, type TabusOptions };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,17 @@
1
1
  type Handler<T = unknown> = (payload: T) => void;
2
+ interface TabusOptions {
3
+ /**
4
+ * Minimum time in milliseconds between emitted messages.
5
+ * When set, emit() calls that arrive faster than this interval
6
+ * are discarded. Useful for high-frequency events like mousemove
7
+ * or scroll to prevent flooding the main thread.
8
+ *
9
+ * @example
10
+ * // Allow at most one message every 16ms (~60fps)
11
+ * const bus = new Tabus('canvas', { throttle: 16 })
12
+ */
13
+ throttle?: number;
14
+ }
2
15
  type EventMap = Record<string, unknown>;
3
16
  interface TabusMessage<T = unknown> {
4
17
  tabId: string;
@@ -32,6 +45,8 @@ declare class Tabus<Events extends EventMap = EventMap> {
32
45
  private readonly localHandlers;
33
46
  private destroyed;
34
47
  private joinTimer;
48
+ private readonly throttleMs;
49
+ private lastEmitAt;
35
50
  /**
36
51
  * Creates a new Tabus instance on the given channel.
37
52
  *
@@ -43,6 +58,8 @@ declare class Tabus<Events extends EventMap = EventMap> {
43
58
  *
44
59
  * @param channelName - Shared channel name (default: `"tabus"`). All
45
60
  * instances with the same channel name can communicate with each other.
61
+ * @param options - Configuration options for this instance.
62
+ * @param options.throttle - Minimum ms between emitted messages (default: 0, no throttle).
46
63
  *
47
64
  * @example
48
65
  * ```ts
@@ -51,7 +68,7 @@ declare class Tabus<Events extends EventMap = EventMap> {
51
68
  * bus.on("tab:join", ({ tabId }) => console.log("peer joined:", tabId));
52
69
  * ```
53
70
  */
54
- constructor(channelName?: string);
71
+ constructor(channelName?: string, options?: TabusOptions);
55
72
  /**
56
73
  * Subscribes to an event, including internal lifecycle events
57
74
  * `"tab:join"` and `"tab:leave"`.
@@ -136,4 +153,4 @@ interface ITransport {
136
153
  destroy(): void;
137
154
  }
138
155
 
139
- export { type EventMap, type Handler, type ITransport, type InternalEvents, Tabus, type TabusMessage };
156
+ export { type EventMap, type Handler, type ITransport, type InternalEvents, Tabus, type TabusMessage, type TabusOptions };
package/dist/index.js CHANGED
@@ -102,6 +102,8 @@ var Tabus = class {
102
102
  *
103
103
  * @param channelName - Shared channel name (default: `"tabus"`). All
104
104
  * instances with the same channel name can communicate with each other.
105
+ * @param options - Configuration options for this instance.
106
+ * @param options.throttle - Minimum ms between emitted messages (default: 0, no throttle).
105
107
  *
106
108
  * @example
107
109
  * ```ts
@@ -110,11 +112,13 @@ var Tabus = class {
110
112
  * bus.on("tab:join", ({ tabId }) => console.log("peer joined:", tabId));
111
113
  * ```
112
114
  */
113
- constructor(channelName) {
115
+ constructor(channelName, options) {
114
116
  this.localHandlers = /* @__PURE__ */ new Map();
115
117
  this.destroyed = false;
116
118
  this.joinTimer = null;
119
+ this.lastEmitAt = 0;
117
120
  this.tabId = generateId();
121
+ this.throttleMs = options?.throttle ?? 0;
118
122
  const name = channelName ?? "tabus";
119
123
  this.transport = typeof BroadcastChannel !== "undefined" ? new BroadcastTransport(name) : new MemoryTransport(name);
120
124
  this.transport.onMessage((msg) => {
@@ -193,6 +197,11 @@ var Tabus = class {
193
197
  */
194
198
  emit(event, payload) {
195
199
  if (this.destroyed) return this;
200
+ if (this.throttleMs > 0) {
201
+ const now = Date.now();
202
+ if (now - this.lastEmitAt < this.throttleMs) return this;
203
+ this.lastEmitAt = now;
204
+ }
196
205
  this.transport.send({ tabId: this.tabId, event: String(event), payload });
197
206
  return this;
198
207
  }
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/core/generate-id.ts","../src/transport/memory.transport.ts","../src/transport/broadcast.transport.ts","../src/core/tabus.ts"],"sourcesContent":["export { Tabus } from \"./core/tabus\";\r\nexport type { ITransport } from \"./transport/transport.interface\";\r\nexport type { EventMap, InternalEvents, Handler, TabusMessage } from \"./core/types\";\r\n","/**\r\n * Generates a RFC-4122 v4 UUID string.\r\n * Uses `crypto.randomUUID()` when available; falls back to a `Math.random`-based\r\n * implementation for environments that lack the Web Crypto API.\r\n */\r\nexport const generateId = (): string => {\r\n if (\r\n typeof crypto !== \"undefined\" &&\r\n typeof (crypto as Crypto).randomUUID === \"function\"\r\n ) {\r\n return (crypto as Crypto).randomUUID();\r\n }\r\n\r\n return \"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx\".replace(/[xy]/g, (c) => {\r\n const r = (Math.random() * 16) | 0;\r\n return (c === \"x\" ? r : (r & 0x3) | 0x8).toString(16);\r\n });\r\n}\r\n","import type { ITransport } from \"./transport.interface\";\r\nimport type { TabusMessage } from \"../core/types\";\r\n\r\n// Module-level registries shared across all MemoryTransport instances.\r\nconst channels = new Map<string, Set<(msg: TabusMessage) => void>>();\r\n// Tracks which channels have already been warned, so the warning fires once per channel.\r\nconst warnedChannels = new Set<string>();\r\n\r\n/**\r\n * In-process transport used when `BroadcastChannel` is unavailable.\r\n * All instances sharing the same `channelName` communicate via a shared Set\r\n * of listeners. Unlike `BroadcastTransport`, the sender's own listener IS\r\n * called by `send()` — deduplication is the responsibility of the caller\r\n * (i.e. `Tabus`).\r\n */\r\nexport class MemoryTransport implements ITransport {\r\n private readonly channelName: string;\r\n private listener: ((msg: TabusMessage) => void) | null = null;\r\n\r\n constructor(channelName: string) {\r\n this.channelName = channelName;\r\n\r\n if (!channels.has(channelName)) {\r\n channels.set(channelName, new Set());\r\n }\r\n\r\n if (!warnedChannels.has(channelName)) {\r\n console.warn(\r\n `[tabus] BroadcastChannel is not available. Falling back to in-memory bus (channel: \"${channelName}\").`,\r\n );\r\n warnedChannels.add(channelName);\r\n }\r\n }\r\n\r\n send(msg: TabusMessage): void {\r\n channels.get(this.channelName)?.forEach((l) => l(msg));\r\n }\r\n\r\n onMessage(listener: (msg: TabusMessage) => void): void {\r\n // Remove any previously registered listener before swapping in the new one.\r\n if (this.listener) {\r\n channels.get(this.channelName)?.delete(this.listener);\r\n }\r\n\r\n this.listener = listener;\r\n channels.get(this.channelName)?.add(listener);\r\n }\r\n\r\n destroy(): void {\r\n if (this.listener) {\r\n channels.get(this.channelName)?.delete(this.listener);\r\n this.listener = null;\r\n }\r\n }\r\n}\r\n\r\n/**\r\n * Resets all module-level state.\r\n * @internal Only for use in unit tests.\r\n */\r\nexport function __resetMemoryBus(): void {\r\n channels.clear();\r\n warnedChannels.clear();\r\n}\r\n","import type { ITransport } from \"./transport.interface\";\r\nimport type { TabusMessage } from \"../core/types\";\r\n\r\n/** Transport backed by the browser / worker `BroadcastChannel` API. */\r\nexport class BroadcastTransport implements ITransport {\r\n private readonly bc: BroadcastChannel;\r\n\r\n constructor(channelName: string) {\r\n this.bc = new BroadcastChannel(channelName);\r\n }\r\n\r\n send(msg: TabusMessage): void {\r\n this.bc.postMessage(msg);\r\n }\r\n\r\n onMessage(listener: (msg: TabusMessage) => void): void {\r\n this.bc.addEventListener(\"message\", (ev: MessageEvent<TabusMessage>) =>\r\n listener(ev.data),\r\n );\r\n }\r\n\r\n destroy(): void {\r\n this.bc.close();\r\n }\r\n}\r\n","import { generateId } from \"./generate-id\";\r\nimport { MemoryTransport } from \"../transport/memory.transport\";\r\nimport { BroadcastTransport } from \"../transport/broadcast.transport\";\r\nimport type { ITransport } from \"../transport/transport.interface\";\r\nimport type { Handler, EventMap, TabusMessage, FullEventMap } from \"./types\";\r\n\r\nexport class Tabus<Events extends EventMap = EventMap> {\r\n /**\r\n * Unique identifier for this tab / instance.\r\n * Generated once per instance using `crypto.randomUUID()` or a manual fallback.\r\n *\r\n * @example\r\n * ```ts\r\n * const bus = new Tabus();\r\n * console.log(bus.tabId); // \"3f2a…\"\r\n * ```\r\n */\r\n readonly tabId: string;\r\n\r\n private readonly transport: ITransport;\r\n private readonly localHandlers = new Map<string, Set<Handler>>();\r\n private destroyed = false;\r\n private joinTimer: ReturnType<typeof setTimeout> | null = null;\r\n\r\n /**\r\n * Creates a new Tabus instance on the given channel.\r\n *\r\n * Uses `BroadcastChannel` when available (cross-tab communication); falls\r\n * back to an in-process in-memory bus otherwise.\r\n *\r\n * **`tab:join` is emitted asynchronously** (via `setTimeout 0`) so handlers\r\n * can be registered on the instance before the event is broadcast.\r\n *\r\n * @param channelName - Shared channel name (default: `\"tabus\"`). All\r\n * instances with the same channel name can communicate with each other.\r\n *\r\n * @example\r\n * ```ts\r\n * type MyEvents = { data: { value: number }; reset: void };\r\n * const bus = new Tabus<MyEvents>(\"my-app\");\r\n * bus.on(\"tab:join\", ({ tabId }) => console.log(\"peer joined:\", tabId));\r\n * ```\r\n */\r\n constructor(channelName?: string) {\r\n this.tabId = generateId();\r\n const name = channelName ?? \"tabus\";\r\n\r\n this.transport = typeof BroadcastChannel !== \"undefined\" ? new BroadcastTransport(name) : new MemoryTransport(name);\r\n\r\n // Deduplication: ignore messages that originated from this instance.\r\n this.transport.onMessage((msg: TabusMessage) => {\r\n if (msg.tabId === this.tabId) return;\r\n this.dispatch(msg.event, msg.payload);\r\n });\r\n\r\n // Defer so callers can register tab:join handlers synchronously after\r\n // the constructor returns before the broadcast goes out.\r\n this.joinTimer = setTimeout(() => {\r\n this.joinTimer = null;\r\n if (!this.destroyed) {\r\n this.transport.send({\r\n tabId : this.tabId,\r\n event : \"tab:join\",\r\n payload: { tabId: this.tabId },\r\n });\r\n }\r\n }, 0);\r\n }\r\n\r\n /**\r\n * Subscribes to an event, including internal lifecycle events\r\n * `\"tab:join\"` and `\"tab:leave\"`.\r\n *\r\n * @param event - The event name to listen to.\r\n * @param handler - Callback invoked with the event payload when the event fires.\r\n * @returns `this` for chaining.\r\n *\r\n * @example\r\n * ```ts\r\n * bus\r\n * .on(\"data\", ({ value }) => console.log(value))\r\n * .on(\"tab:join\", ({ tabId }) => console.log(\"new peer:\", tabId));\r\n * ```\r\n */\r\n on<K extends keyof FullEventMap<Events>>(\r\n event: K,\r\n handler: Handler<FullEventMap<Events>[K]>,\r\n ): this {\r\n if (this.destroyed) return this;\r\n\r\n const key = String(event);\r\n\r\n if (!this.localHandlers.has(key)) {\r\n this.localHandlers.set(key, new Set());\r\n }\r\n\r\n this.localHandlers.get(key)!.add(handler as Handler);\r\n return this;\r\n }\r\n\r\n /**\r\n * Unsubscribes a previously registered handler.\r\n *\r\n * @param event - The event name.\r\n * @param handler - The exact same handler reference passed to `on()`.\r\n * @returns `this` for chaining.\r\n *\r\n * @example\r\n * ```ts\r\n * const handler = (p: { value: number }) => console.log(p.value);\r\n * bus.on(\"data\", handler);\r\n * // later:\r\n * bus.off(\"data\", handler);\r\n * ```\r\n */\r\n off<K extends keyof FullEventMap<Events>>(\r\n event: K,\r\n handler: Handler<FullEventMap<Events>[K]>,\r\n ): this {\r\n this.localHandlers.get(String(event))?.delete(handler as Handler);\r\n return this;\r\n }\r\n\r\n /**\r\n * Broadcasts an event to **all other instances** on the same channel.\r\n *\r\n * **The emitting instance does NOT receive its own event.** If local handling\r\n * is needed, call the handler directly after emitting.\r\n *\r\n * @param event - A user-defined event name (internal events cannot be emitted).\r\n * @param payload - The value to broadcast.\r\n * @returns `this` for chaining.\r\n *\r\n * @example\r\n * ```ts\r\n * bus.emit(\"data\", { value: 42 });\r\n * // other tabs receive { value: 42 }; this tab does not.\r\n * ```\r\n */\r\n emit<K extends keyof Events>(event: K, payload: Events[K]): this {\r\n if (this.destroyed) return this;\r\n this.transport.send({ tabId: this.tabId, event: String(event), payload });\r\n return this;\r\n }\r\n\r\n /**\r\n * Broadcasts `\"tab:leave\"`, closes the transport, and removes all handlers.\r\n * Safe to call multiple times — subsequent calls are no-ops.\r\n *\r\n * If the deferred `\"tab:join\"` has not yet fired (i.e., the instance is\r\n * destroyed within the same tick it was created), neither `\"tab:join\"` nor\r\n * `\"tab:leave\"` are sent to peers.\r\n *\r\n * @example\r\n * ```ts\r\n * window.addEventListener(\"beforeunload\", () => bus.destroy());\r\n * ```\r\n */\r\n destroy(): void {\r\n if (this.destroyed) return;\r\n this.destroyed = true;\r\n\r\n if (this.joinTimer !== null) {\r\n // tab:join hasn't fired yet — cancel it; peers don't know we existed,\r\n // so there is nothing to announce on departure either.\r\n clearTimeout(this.joinTimer);\r\n this.joinTimer = null;\r\n } else {\r\n // tab:join already fired — announce departure to peers.\r\n this.transport.send({\r\n tabId : this.tabId,\r\n event : \"tab:leave\",\r\n payload: { tabId: this.tabId },\r\n });\r\n }\r\n\r\n this.transport.destroy();\r\n this.localHandlers.clear();\r\n }\r\n\r\n /** Invokes all registered handlers for the given event with the payload. */\r\n private dispatch(event: string, payload: unknown): void {\r\n this.localHandlers.get(event)?.forEach((h) => h(payload));\r\n }\r\n}\r\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACKO,IAAM,aAAa,MAAc;AACtC,MACE,OAAO,WAAW,eAClB,OAAQ,OAAkB,eAAe,YACzC;AACA,WAAQ,OAAkB,WAAW;AAAA,EACvC;AAEA,SAAO,uCAAuC,QAAQ,SAAS,CAAC,MAAM;AACpE,UAAM,IAAK,KAAK,OAAO,IAAI,KAAM;AACjC,YAAQ,MAAM,MAAM,IAAK,IAAI,IAAO,GAAK,SAAS,EAAE;AAAA,EACtD,CAAC;AACH;;;ACbA,IAAM,WAAW,oBAAI,IAA8C;AAEnE,IAAM,iBAAiB,oBAAI,IAAY;AAShC,IAAM,kBAAN,MAA4C;AAAA,EAIjD,YAAY,aAAqB;AAFjC,SAAQ,WAAiD;AAGvD,SAAK,cAAc;AAEnB,QAAI,CAAC,SAAS,IAAI,WAAW,GAAG;AAC9B,eAAS,IAAI,aAAa,oBAAI,IAAI,CAAC;AAAA,IACrC;AAEA,QAAI,CAAC,eAAe,IAAI,WAAW,GAAG;AACpC,cAAQ;AAAA,QACN,uFAAuF,WAAW;AAAA,MACpG;AACA,qBAAe,IAAI,WAAW;AAAA,IAChC;AAAA,EACF;AAAA,EAEA,KAAK,KAAyB;AAC5B,aAAS,IAAI,KAAK,WAAW,GAAG,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC;AAAA,EACvD;AAAA,EAEA,UAAU,UAA6C;AAErD,QAAI,KAAK,UAAU;AACjB,eAAS,IAAI,KAAK,WAAW,GAAG,OAAO,KAAK,QAAQ;AAAA,IACtD;AAEA,SAAK,WAAW;AAChB,aAAS,IAAI,KAAK,WAAW,GAAG,IAAI,QAAQ;AAAA,EAC9C;AAAA,EAEA,UAAgB;AACd,QAAI,KAAK,UAAU;AACjB,eAAS,IAAI,KAAK,WAAW,GAAG,OAAO,KAAK,QAAQ;AACpD,WAAK,WAAW;AAAA,IAClB;AAAA,EACF;AACF;;;AClDO,IAAM,qBAAN,MAA+C;AAAA,EAGpD,YAAY,aAAqB;AAC/B,SAAK,KAAK,IAAI,iBAAiB,WAAW;AAAA,EAC5C;AAAA,EAEA,KAAK,KAAyB;AAC5B,SAAK,GAAG,YAAY,GAAG;AAAA,EACzB;AAAA,EAEA,UAAU,UAA6C;AACrD,SAAK,GAAG;AAAA,MAAiB;AAAA,MAAW,CAAC,OACnC,SAAS,GAAG,IAAI;AAAA,IAClB;AAAA,EACF;AAAA,EAEA,UAAgB;AACd,SAAK,GAAG,MAAM;AAAA,EAChB;AACF;;;AClBO,IAAM,QAAN,MAAgD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAqCrD,YAAY,aAAsB;AAvBlC,SAAiB,gBAAgB,oBAAI,IAA0B;AAC/D,SAAQ,YAAY;AACpB,SAAQ,YAAkD;AAsBxD,SAAK,QAAQ,WAAW;AACxB,UAAM,OAAO,eAAe;AAE5B,SAAK,YAAY,OAAO,qBAAqB,cAAc,IAAI,mBAAmB,IAAI,IAAI,IAAI,gBAAgB,IAAI;AAGlH,SAAK,UAAU,UAAU,CAAC,QAAsB;AAC9C,UAAI,IAAI,UAAU,KAAK,MAAO;AAC9B,WAAK,SAAS,IAAI,OAAO,IAAI,OAAO;AAAA,IACtC,CAAC;AAID,SAAK,YAAY,WAAW,MAAM;AAChC,WAAK,YAAY;AACjB,UAAI,CAAC,KAAK,WAAW;AACnB,aAAK,UAAU,KAAK;AAAA,UAClB,OAAS,KAAK;AAAA,UACd,OAAS;AAAA,UACT,SAAS,EAAE,OAAO,KAAK,MAAM;AAAA,QAC/B,CAAC;AAAA,MACH;AAAA,IACF,GAAG,CAAC;AAAA,EACN;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAiBA,GACE,OACA,SACM;AACN,QAAI,KAAK,UAAW,QAAO;AAE3B,UAAM,MAAM,OAAO,KAAK;AAExB,QAAI,CAAC,KAAK,cAAc,IAAI,GAAG,GAAG;AAChC,WAAK,cAAc,IAAI,KAAK,oBAAI,IAAI,CAAC;AAAA,IACvC;AAEA,SAAK,cAAc,IAAI,GAAG,EAAG,IAAI,OAAkB;AACnD,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAiBA,IACE,OACA,SACM;AACN,SAAK,cAAc,IAAI,OAAO,KAAK,CAAC,GAAG,OAAO,OAAkB;AAChE,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAkBA,KAA6B,OAAU,SAA0B;AAC/D,QAAI,KAAK,UAAW,QAAO;AAC3B,SAAK,UAAU,KAAK,EAAE,OAAO,KAAK,OAAO,OAAO,OAAO,KAAK,GAAG,QAAQ,CAAC;AACxE,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAeA,UAAgB;AACd,QAAI,KAAK,UAAW;AACpB,SAAK,YAAY;AAEjB,QAAI,KAAK,cAAc,MAAM;AAG3B,mBAAa,KAAK,SAAS;AAC3B,WAAK,YAAY;AAAA,IACnB,OAAO;AAEL,WAAK,UAAU,KAAK;AAAA,QAClB,OAAS,KAAK;AAAA,QACd,OAAS;AAAA,QACT,SAAS,EAAE,OAAO,KAAK,MAAM;AAAA,MAC/B,CAAC;AAAA,IACH;AAEA,SAAK,UAAU,QAAQ;AACvB,SAAK,cAAc,MAAM;AAAA,EAC3B;AAAA;AAAA,EAGQ,SAAS,OAAe,SAAwB;AACtD,SAAK,cAAc,IAAI,KAAK,GAAG,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;AAAA,EAC1D;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../src/core/generate-id.ts","../src/transport/memory.transport.ts","../src/transport/broadcast.transport.ts","../src/core/tabus.ts"],"sourcesContent":["export { Tabus } from \"./core/tabus\";\r\nexport type { ITransport } from \"./transport/transport.interface\";\r\nexport type { EventMap, InternalEvents, Handler, TabusMessage, TabusOptions } from \"./core/types\";\r\n","/**\r\n * Generates a RFC-4122 v4 UUID string.\r\n * Uses `crypto.randomUUID()` when available; falls back to a `Math.random`-based\r\n * implementation for environments that lack the Web Crypto API.\r\n */\r\nexport const generateId = (): string => {\r\n if (\r\n typeof crypto !== \"undefined\" &&\r\n typeof (crypto as Crypto).randomUUID === \"function\"\r\n ) {\r\n return (crypto as Crypto).randomUUID();\r\n }\r\n\r\n return \"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx\".replace(/[xy]/g, (c) => {\r\n const r = (Math.random() * 16) | 0;\r\n return (c === \"x\" ? r : (r & 0x3) | 0x8).toString(16);\r\n });\r\n}\r\n","import type { ITransport } from \"./transport.interface\";\r\nimport type { TabusMessage } from \"../core/types\";\r\n\r\n// Module-level registries shared across all MemoryTransport instances.\r\nconst channels = new Map<string, Set<(msg: TabusMessage) => void>>();\r\n// Tracks which channels have already been warned, so the warning fires once per channel.\r\nconst warnedChannels = new Set<string>();\r\n\r\n/**\r\n * In-process transport used when `BroadcastChannel` is unavailable.\r\n * All instances sharing the same `channelName` communicate via a shared Set\r\n * of listeners. Unlike `BroadcastTransport`, the sender's own listener IS\r\n * called by `send()` — deduplication is the responsibility of the caller\r\n * (i.e. `Tabus`).\r\n */\r\nexport class MemoryTransport implements ITransport {\r\n private readonly channelName: string;\r\n private listener: ((msg: TabusMessage) => void) | null = null;\r\n\r\n constructor(channelName: string) {\r\n this.channelName = channelName;\r\n\r\n if (!channels.has(channelName)) {\r\n channels.set(channelName, new Set());\r\n }\r\n\r\n if (!warnedChannels.has(channelName)) {\r\n console.warn(\r\n `[tabus] BroadcastChannel is not available. Falling back to in-memory bus (channel: \"${channelName}\").`,\r\n );\r\n warnedChannels.add(channelName);\r\n }\r\n }\r\n\r\n send(msg: TabusMessage): void {\r\n channels.get(this.channelName)?.forEach((l) => l(msg));\r\n }\r\n\r\n onMessage(listener: (msg: TabusMessage) => void): void {\r\n // Remove any previously registered listener before swapping in the new one.\r\n if (this.listener) {\r\n channels.get(this.channelName)?.delete(this.listener);\r\n }\r\n\r\n this.listener = listener;\r\n channels.get(this.channelName)?.add(listener);\r\n }\r\n\r\n destroy(): void {\r\n if (this.listener) {\r\n channels.get(this.channelName)?.delete(this.listener);\r\n this.listener = null;\r\n }\r\n }\r\n}\r\n\r\n/**\r\n * Resets all module-level state.\r\n * @internal Only for use in unit tests.\r\n */\r\nexport function __resetMemoryBus(): void {\r\n channels.clear();\r\n warnedChannels.clear();\r\n}\r\n","import type { ITransport } from \"./transport.interface\";\r\nimport type { TabusMessage } from \"../core/types\";\r\n\r\n/** Transport backed by the browser / worker `BroadcastChannel` API. */\r\nexport class BroadcastTransport implements ITransport {\r\n private readonly bc: BroadcastChannel;\r\n\r\n constructor(channelName: string) {\r\n this.bc = new BroadcastChannel(channelName);\r\n }\r\n\r\n send(msg: TabusMessage): void {\r\n this.bc.postMessage(msg);\r\n }\r\n\r\n onMessage(listener: (msg: TabusMessage) => void): void {\r\n this.bc.addEventListener(\"message\", (ev: MessageEvent<TabusMessage>) =>\r\n listener(ev.data),\r\n );\r\n }\r\n\r\n destroy(): void {\r\n this.bc.close();\r\n }\r\n}\r\n","import { generateId } from \"./generate-id\";\r\nimport { MemoryTransport } from \"../transport/memory.transport\";\r\nimport { BroadcastTransport } from \"../transport/broadcast.transport\";\r\nimport type { ITransport } from \"../transport/transport.interface\";\r\nimport type { Handler, EventMap, TabusMessage, FullEventMap, TabusOptions } from \"./types\";\r\n\r\nexport class Tabus<Events extends EventMap = EventMap> {\r\n /**\r\n * Unique identifier for this tab / instance.\r\n * Generated once per instance using `crypto.randomUUID()` or a manual fallback.\r\n *\r\n * @example\r\n * ```ts\r\n * const bus = new Tabus();\r\n * console.log(bus.tabId); // \"3f2a…\"\r\n * ```\r\n */\r\n readonly tabId: string;\r\n\r\n private readonly transport: ITransport;\r\n private readonly localHandlers = new Map<string, Set<Handler>>();\r\n private destroyed = false;\r\n private joinTimer: ReturnType<typeof setTimeout> | null = null;\r\n private readonly throttleMs: number;\r\n private lastEmitAt = 0;\r\n\r\n /**\r\n * Creates a new Tabus instance on the given channel.\r\n *\r\n * Uses `BroadcastChannel` when available (cross-tab communication); falls\r\n * back to an in-process in-memory bus otherwise.\r\n *\r\n * **`tab:join` is emitted asynchronously** (via `setTimeout 0`) so handlers\r\n * can be registered on the instance before the event is broadcast.\r\n *\r\n * @param channelName - Shared channel name (default: `\"tabus\"`). All\r\n * instances with the same channel name can communicate with each other.\r\n * @param options - Configuration options for this instance.\r\n * @param options.throttle - Minimum ms between emitted messages (default: 0, no throttle).\r\n *\r\n * @example\r\n * ```ts\r\n * type MyEvents = { data: { value: number }; reset: void };\r\n * const bus = new Tabus<MyEvents>(\"my-app\");\r\n * bus.on(\"tab:join\", ({ tabId }) => console.log(\"peer joined:\", tabId));\r\n * ```\r\n */\r\n constructor(channelName?: string, options?: TabusOptions) {\r\n this.tabId = generateId();\r\n this.throttleMs = options?.throttle ?? 0;\r\n const name = channelName ?? \"tabus\";\r\n\r\n this.transport = typeof BroadcastChannel !== \"undefined\" ? new BroadcastTransport(name) : new MemoryTransport(name);\r\n\r\n // Deduplication: ignore messages that originated from this instance.\r\n this.transport.onMessage((msg: TabusMessage) => {\r\n if (msg.tabId === this.tabId) return;\r\n this.dispatch(msg.event, msg.payload);\r\n });\r\n\r\n // Defer so callers can register tab:join handlers synchronously after\r\n // the constructor returns before the broadcast goes out.\r\n this.joinTimer = setTimeout(() => {\r\n this.joinTimer = null;\r\n if (!this.destroyed) {\r\n this.transport.send({\r\n tabId : this.tabId,\r\n event : \"tab:join\",\r\n payload: { tabId: this.tabId },\r\n });\r\n }\r\n }, 0);\r\n }\r\n\r\n /**\r\n * Subscribes to an event, including internal lifecycle events\r\n * `\"tab:join\"` and `\"tab:leave\"`.\r\n *\r\n * @param event - The event name to listen to.\r\n * @param handler - Callback invoked with the event payload when the event fires.\r\n * @returns `this` for chaining.\r\n *\r\n * @example\r\n * ```ts\r\n * bus\r\n * .on(\"data\", ({ value }) => console.log(value))\r\n * .on(\"tab:join\", ({ tabId }) => console.log(\"new peer:\", tabId));\r\n * ```\r\n */\r\n on<K extends keyof FullEventMap<Events>>(\r\n event: K,\r\n handler: Handler<FullEventMap<Events>[K]>,\r\n ): this {\r\n if (this.destroyed) return this;\r\n\r\n const key = String(event);\r\n\r\n if (!this.localHandlers.has(key)) {\r\n this.localHandlers.set(key, new Set());\r\n }\r\n\r\n this.localHandlers.get(key)!.add(handler as Handler);\r\n return this;\r\n }\r\n\r\n /**\r\n * Unsubscribes a previously registered handler.\r\n *\r\n * @param event - The event name.\r\n * @param handler - The exact same handler reference passed to `on()`.\r\n * @returns `this` for chaining.\r\n *\r\n * @example\r\n * ```ts\r\n * const handler = (p: { value: number }) => console.log(p.value);\r\n * bus.on(\"data\", handler);\r\n * // later:\r\n * bus.off(\"data\", handler);\r\n * ```\r\n */\r\n off<K extends keyof FullEventMap<Events>>(\r\n event: K,\r\n handler: Handler<FullEventMap<Events>[K]>,\r\n ): this {\r\n this.localHandlers.get(String(event))?.delete(handler as Handler);\r\n return this;\r\n }\r\n\r\n /**\r\n * Broadcasts an event to **all other instances** on the same channel.\r\n *\r\n * **The emitting instance does NOT receive its own event.** If local handling\r\n * is needed, call the handler directly after emitting.\r\n *\r\n * @param event - A user-defined event name (internal events cannot be emitted).\r\n * @param payload - The value to broadcast.\r\n * @returns `this` for chaining.\r\n *\r\n * @example\r\n * ```ts\r\n * bus.emit(\"data\", { value: 42 });\r\n * // other tabs receive { value: 42 }; this tab does not.\r\n * ```\r\n */\r\n emit<K extends keyof Events>(event: K, payload: Events[K]): this {\r\n if (this.destroyed) return this;\r\n\r\n if (this.throttleMs > 0) {\r\n const now = Date.now();\r\n if (now - this.lastEmitAt < this.throttleMs) return this;\r\n this.lastEmitAt = now;\r\n }\r\n\r\n this.transport.send({ tabId: this.tabId, event: String(event), payload });\r\n return this;\r\n }\r\n\r\n /**\r\n * Broadcasts `\"tab:leave\"`, closes the transport, and removes all handlers.\r\n * Safe to call multiple times — subsequent calls are no-ops.\r\n *\r\n * If the deferred `\"tab:join\"` has not yet fired (i.e., the instance is\r\n * destroyed within the same tick it was created), neither `\"tab:join\"` nor\r\n * `\"tab:leave\"` are sent to peers.\r\n *\r\n * @example\r\n * ```ts\r\n * window.addEventListener(\"beforeunload\", () => bus.destroy());\r\n * ```\r\n */\r\n destroy(): void {\r\n if (this.destroyed) return;\r\n this.destroyed = true;\r\n\r\n if (this.joinTimer !== null) {\r\n // tab:join hasn't fired yet — cancel it; peers don't know we existed,\r\n // so there is nothing to announce on departure either.\r\n clearTimeout(this.joinTimer);\r\n this.joinTimer = null;\r\n } else {\r\n // tab:join already fired — announce departure to peers.\r\n this.transport.send({\r\n tabId : this.tabId,\r\n event : \"tab:leave\",\r\n payload: { tabId: this.tabId },\r\n });\r\n }\r\n\r\n this.transport.destroy();\r\n this.localHandlers.clear();\r\n }\r\n\r\n /** Invokes all registered handlers for the given event with the payload. */\r\n private dispatch(event: string, payload: unknown): void {\r\n this.localHandlers.get(event)?.forEach((h) => h(payload));\r\n }\r\n}\r\n"],"mappings":"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","names":[]}
package/dist/index.mjs CHANGED
@@ -76,6 +76,8 @@ var Tabus = class {
76
76
  *
77
77
  * @param channelName - Shared channel name (default: `"tabus"`). All
78
78
  * instances with the same channel name can communicate with each other.
79
+ * @param options - Configuration options for this instance.
80
+ * @param options.throttle - Minimum ms between emitted messages (default: 0, no throttle).
79
81
  *
80
82
  * @example
81
83
  * ```ts
@@ -84,11 +86,13 @@ var Tabus = class {
84
86
  * bus.on("tab:join", ({ tabId }) => console.log("peer joined:", tabId));
85
87
  * ```
86
88
  */
87
- constructor(channelName) {
89
+ constructor(channelName, options) {
88
90
  this.localHandlers = /* @__PURE__ */ new Map();
89
91
  this.destroyed = false;
90
92
  this.joinTimer = null;
93
+ this.lastEmitAt = 0;
91
94
  this.tabId = generateId();
95
+ this.throttleMs = options?.throttle ?? 0;
92
96
  const name = channelName ?? "tabus";
93
97
  this.transport = typeof BroadcastChannel !== "undefined" ? new BroadcastTransport(name) : new MemoryTransport(name);
94
98
  this.transport.onMessage((msg) => {
@@ -167,6 +171,11 @@ var Tabus = class {
167
171
  */
168
172
  emit(event, payload) {
169
173
  if (this.destroyed) return this;
174
+ if (this.throttleMs > 0) {
175
+ const now = Date.now();
176
+ if (now - this.lastEmitAt < this.throttleMs) return this;
177
+ this.lastEmitAt = now;
178
+ }
170
179
  this.transport.send({ tabId: this.tabId, event: String(event), payload });
171
180
  return this;
172
181
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/core/generate-id.ts","../src/transport/memory.transport.ts","../src/transport/broadcast.transport.ts","../src/core/tabus.ts"],"sourcesContent":["/**\r\n * Generates a RFC-4122 v4 UUID string.\r\n * Uses `crypto.randomUUID()` when available; falls back to a `Math.random`-based\r\n * implementation for environments that lack the Web Crypto API.\r\n */\r\nexport const generateId = (): string => {\r\n if (\r\n typeof crypto !== \"undefined\" &&\r\n typeof (crypto as Crypto).randomUUID === \"function\"\r\n ) {\r\n return (crypto as Crypto).randomUUID();\r\n }\r\n\r\n return \"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx\".replace(/[xy]/g, (c) => {\r\n const r = (Math.random() * 16) | 0;\r\n return (c === \"x\" ? r : (r & 0x3) | 0x8).toString(16);\r\n });\r\n}\r\n","import type { ITransport } from \"./transport.interface\";\r\nimport type { TabusMessage } from \"../core/types\";\r\n\r\n// Module-level registries shared across all MemoryTransport instances.\r\nconst channels = new Map<string, Set<(msg: TabusMessage) => void>>();\r\n// Tracks which channels have already been warned, so the warning fires once per channel.\r\nconst warnedChannels = new Set<string>();\r\n\r\n/**\r\n * In-process transport used when `BroadcastChannel` is unavailable.\r\n * All instances sharing the same `channelName` communicate via a shared Set\r\n * of listeners. Unlike `BroadcastTransport`, the sender's own listener IS\r\n * called by `send()` — deduplication is the responsibility of the caller\r\n * (i.e. `Tabus`).\r\n */\r\nexport class MemoryTransport implements ITransport {\r\n private readonly channelName: string;\r\n private listener: ((msg: TabusMessage) => void) | null = null;\r\n\r\n constructor(channelName: string) {\r\n this.channelName = channelName;\r\n\r\n if (!channels.has(channelName)) {\r\n channels.set(channelName, new Set());\r\n }\r\n\r\n if (!warnedChannels.has(channelName)) {\r\n console.warn(\r\n `[tabus] BroadcastChannel is not available. Falling back to in-memory bus (channel: \"${channelName}\").`,\r\n );\r\n warnedChannels.add(channelName);\r\n }\r\n }\r\n\r\n send(msg: TabusMessage): void {\r\n channels.get(this.channelName)?.forEach((l) => l(msg));\r\n }\r\n\r\n onMessage(listener: (msg: TabusMessage) => void): void {\r\n // Remove any previously registered listener before swapping in the new one.\r\n if (this.listener) {\r\n channels.get(this.channelName)?.delete(this.listener);\r\n }\r\n\r\n this.listener = listener;\r\n channels.get(this.channelName)?.add(listener);\r\n }\r\n\r\n destroy(): void {\r\n if (this.listener) {\r\n channels.get(this.channelName)?.delete(this.listener);\r\n this.listener = null;\r\n }\r\n }\r\n}\r\n\r\n/**\r\n * Resets all module-level state.\r\n * @internal Only for use in unit tests.\r\n */\r\nexport function __resetMemoryBus(): void {\r\n channels.clear();\r\n warnedChannels.clear();\r\n}\r\n","import type { ITransport } from \"./transport.interface\";\r\nimport type { TabusMessage } from \"../core/types\";\r\n\r\n/** Transport backed by the browser / worker `BroadcastChannel` API. */\r\nexport class BroadcastTransport implements ITransport {\r\n private readonly bc: BroadcastChannel;\r\n\r\n constructor(channelName: string) {\r\n this.bc = new BroadcastChannel(channelName);\r\n }\r\n\r\n send(msg: TabusMessage): void {\r\n this.bc.postMessage(msg);\r\n }\r\n\r\n onMessage(listener: (msg: TabusMessage) => void): void {\r\n this.bc.addEventListener(\"message\", (ev: MessageEvent<TabusMessage>) =>\r\n listener(ev.data),\r\n );\r\n }\r\n\r\n destroy(): void {\r\n this.bc.close();\r\n }\r\n}\r\n","import { generateId } from \"./generate-id\";\r\nimport { MemoryTransport } from \"../transport/memory.transport\";\r\nimport { BroadcastTransport } from \"../transport/broadcast.transport\";\r\nimport type { ITransport } from \"../transport/transport.interface\";\r\nimport type { Handler, EventMap, TabusMessage, FullEventMap } from \"./types\";\r\n\r\nexport class Tabus<Events extends EventMap = EventMap> {\r\n /**\r\n * Unique identifier for this tab / instance.\r\n * Generated once per instance using `crypto.randomUUID()` or a manual fallback.\r\n *\r\n * @example\r\n * ```ts\r\n * const bus = new Tabus();\r\n * console.log(bus.tabId); // \"3f2a…\"\r\n * ```\r\n */\r\n readonly tabId: string;\r\n\r\n private readonly transport: ITransport;\r\n private readonly localHandlers = new Map<string, Set<Handler>>();\r\n private destroyed = false;\r\n private joinTimer: ReturnType<typeof setTimeout> | null = null;\r\n\r\n /**\r\n * Creates a new Tabus instance on the given channel.\r\n *\r\n * Uses `BroadcastChannel` when available (cross-tab communication); falls\r\n * back to an in-process in-memory bus otherwise.\r\n *\r\n * **`tab:join` is emitted asynchronously** (via `setTimeout 0`) so handlers\r\n * can be registered on the instance before the event is broadcast.\r\n *\r\n * @param channelName - Shared channel name (default: `\"tabus\"`). All\r\n * instances with the same channel name can communicate with each other.\r\n *\r\n * @example\r\n * ```ts\r\n * type MyEvents = { data: { value: number }; reset: void };\r\n * const bus = new Tabus<MyEvents>(\"my-app\");\r\n * bus.on(\"tab:join\", ({ tabId }) => console.log(\"peer joined:\", tabId));\r\n * ```\r\n */\r\n constructor(channelName?: string) {\r\n this.tabId = generateId();\r\n const name = channelName ?? \"tabus\";\r\n\r\n this.transport = typeof BroadcastChannel !== \"undefined\" ? new BroadcastTransport(name) : new MemoryTransport(name);\r\n\r\n // Deduplication: ignore messages that originated from this instance.\r\n this.transport.onMessage((msg: TabusMessage) => {\r\n if (msg.tabId === this.tabId) return;\r\n this.dispatch(msg.event, msg.payload);\r\n });\r\n\r\n // Defer so callers can register tab:join handlers synchronously after\r\n // the constructor returns before the broadcast goes out.\r\n this.joinTimer = setTimeout(() => {\r\n this.joinTimer = null;\r\n if (!this.destroyed) {\r\n this.transport.send({\r\n tabId : this.tabId,\r\n event : \"tab:join\",\r\n payload: { tabId: this.tabId },\r\n });\r\n }\r\n }, 0);\r\n }\r\n\r\n /**\r\n * Subscribes to an event, including internal lifecycle events\r\n * `\"tab:join\"` and `\"tab:leave\"`.\r\n *\r\n * @param event - The event name to listen to.\r\n * @param handler - Callback invoked with the event payload when the event fires.\r\n * @returns `this` for chaining.\r\n *\r\n * @example\r\n * ```ts\r\n * bus\r\n * .on(\"data\", ({ value }) => console.log(value))\r\n * .on(\"tab:join\", ({ tabId }) => console.log(\"new peer:\", tabId));\r\n * ```\r\n */\r\n on<K extends keyof FullEventMap<Events>>(\r\n event: K,\r\n handler: Handler<FullEventMap<Events>[K]>,\r\n ): this {\r\n if (this.destroyed) return this;\r\n\r\n const key = String(event);\r\n\r\n if (!this.localHandlers.has(key)) {\r\n this.localHandlers.set(key, new Set());\r\n }\r\n\r\n this.localHandlers.get(key)!.add(handler as Handler);\r\n return this;\r\n }\r\n\r\n /**\r\n * Unsubscribes a previously registered handler.\r\n *\r\n * @param event - The event name.\r\n * @param handler - The exact same handler reference passed to `on()`.\r\n * @returns `this` for chaining.\r\n *\r\n * @example\r\n * ```ts\r\n * const handler = (p: { value: number }) => console.log(p.value);\r\n * bus.on(\"data\", handler);\r\n * // later:\r\n * bus.off(\"data\", handler);\r\n * ```\r\n */\r\n off<K extends keyof FullEventMap<Events>>(\r\n event: K,\r\n handler: Handler<FullEventMap<Events>[K]>,\r\n ): this {\r\n this.localHandlers.get(String(event))?.delete(handler as Handler);\r\n return this;\r\n }\r\n\r\n /**\r\n * Broadcasts an event to **all other instances** on the same channel.\r\n *\r\n * **The emitting instance does NOT receive its own event.** If local handling\r\n * is needed, call the handler directly after emitting.\r\n *\r\n * @param event - A user-defined event name (internal events cannot be emitted).\r\n * @param payload - The value to broadcast.\r\n * @returns `this` for chaining.\r\n *\r\n * @example\r\n * ```ts\r\n * bus.emit(\"data\", { value: 42 });\r\n * // other tabs receive { value: 42 }; this tab does not.\r\n * ```\r\n */\r\n emit<K extends keyof Events>(event: K, payload: Events[K]): this {\r\n if (this.destroyed) return this;\r\n this.transport.send({ tabId: this.tabId, event: String(event), payload });\r\n return this;\r\n }\r\n\r\n /**\r\n * Broadcasts `\"tab:leave\"`, closes the transport, and removes all handlers.\r\n * Safe to call multiple times — subsequent calls are no-ops.\r\n *\r\n * If the deferred `\"tab:join\"` has not yet fired (i.e., the instance is\r\n * destroyed within the same tick it was created), neither `\"tab:join\"` nor\r\n * `\"tab:leave\"` are sent to peers.\r\n *\r\n * @example\r\n * ```ts\r\n * window.addEventListener(\"beforeunload\", () => bus.destroy());\r\n * ```\r\n */\r\n destroy(): void {\r\n if (this.destroyed) return;\r\n this.destroyed = true;\r\n\r\n if (this.joinTimer !== null) {\r\n // tab:join hasn't fired yet — cancel it; peers don't know we existed,\r\n // so there is nothing to announce on departure either.\r\n clearTimeout(this.joinTimer);\r\n this.joinTimer = null;\r\n } else {\r\n // tab:join already fired — announce departure to peers.\r\n this.transport.send({\r\n tabId : this.tabId,\r\n event : \"tab:leave\",\r\n payload: { tabId: this.tabId },\r\n });\r\n }\r\n\r\n this.transport.destroy();\r\n this.localHandlers.clear();\r\n }\r\n\r\n /** Invokes all registered handlers for the given event with the payload. */\r\n private dispatch(event: string, payload: unknown): void {\r\n this.localHandlers.get(event)?.forEach((h) => h(payload));\r\n }\r\n}\r\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/core/generate-id.ts","../src/transport/memory.transport.ts","../src/transport/broadcast.transport.ts","../src/core/tabus.ts"],"sourcesContent":["/**\r\n * Generates a RFC-4122 v4 UUID string.\r\n * Uses `crypto.randomUUID()` when available; falls back to a `Math.random`-based\r\n * implementation for environments that lack the Web Crypto API.\r\n */\r\nexport const generateId = (): string => {\r\n if (\r\n typeof crypto !== \"undefined\" &&\r\n typeof (crypto as Crypto).randomUUID === \"function\"\r\n ) {\r\n return (crypto as Crypto).randomUUID();\r\n }\r\n\r\n return \"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx\".replace(/[xy]/g, (c) => {\r\n const r = (Math.random() * 16) | 0;\r\n return (c === \"x\" ? r : (r & 0x3) | 0x8).toString(16);\r\n });\r\n}\r\n","import type { ITransport } from \"./transport.interface\";\r\nimport type { TabusMessage } from \"../core/types\";\r\n\r\n// Module-level registries shared across all MemoryTransport instances.\r\nconst channels = new Map<string, Set<(msg: TabusMessage) => void>>();\r\n// Tracks which channels have already been warned, so the warning fires once per channel.\r\nconst warnedChannels = new Set<string>();\r\n\r\n/**\r\n * In-process transport used when `BroadcastChannel` is unavailable.\r\n * All instances sharing the same `channelName` communicate via a shared Set\r\n * of listeners. Unlike `BroadcastTransport`, the sender's own listener IS\r\n * called by `send()` — deduplication is the responsibility of the caller\r\n * (i.e. `Tabus`).\r\n */\r\nexport class MemoryTransport implements ITransport {\r\n private readonly channelName: string;\r\n private listener: ((msg: TabusMessage) => void) | null = null;\r\n\r\n constructor(channelName: string) {\r\n this.channelName = channelName;\r\n\r\n if (!channels.has(channelName)) {\r\n channels.set(channelName, new Set());\r\n }\r\n\r\n if (!warnedChannels.has(channelName)) {\r\n console.warn(\r\n `[tabus] BroadcastChannel is not available. Falling back to in-memory bus (channel: \"${channelName}\").`,\r\n );\r\n warnedChannels.add(channelName);\r\n }\r\n }\r\n\r\n send(msg: TabusMessage): void {\r\n channels.get(this.channelName)?.forEach((l) => l(msg));\r\n }\r\n\r\n onMessage(listener: (msg: TabusMessage) => void): void {\r\n // Remove any previously registered listener before swapping in the new one.\r\n if (this.listener) {\r\n channels.get(this.channelName)?.delete(this.listener);\r\n }\r\n\r\n this.listener = listener;\r\n channels.get(this.channelName)?.add(listener);\r\n }\r\n\r\n destroy(): void {\r\n if (this.listener) {\r\n channels.get(this.channelName)?.delete(this.listener);\r\n this.listener = null;\r\n }\r\n }\r\n}\r\n\r\n/**\r\n * Resets all module-level state.\r\n * @internal Only for use in unit tests.\r\n */\r\nexport function __resetMemoryBus(): void {\r\n channels.clear();\r\n warnedChannels.clear();\r\n}\r\n","import type { ITransport } from \"./transport.interface\";\r\nimport type { TabusMessage } from \"../core/types\";\r\n\r\n/** Transport backed by the browser / worker `BroadcastChannel` API. */\r\nexport class BroadcastTransport implements ITransport {\r\n private readonly bc: BroadcastChannel;\r\n\r\n constructor(channelName: string) {\r\n this.bc = new BroadcastChannel(channelName);\r\n }\r\n\r\n send(msg: TabusMessage): void {\r\n this.bc.postMessage(msg);\r\n }\r\n\r\n onMessage(listener: (msg: TabusMessage) => void): void {\r\n this.bc.addEventListener(\"message\", (ev: MessageEvent<TabusMessage>) =>\r\n listener(ev.data),\r\n );\r\n }\r\n\r\n destroy(): void {\r\n this.bc.close();\r\n }\r\n}\r\n","import { generateId } from \"./generate-id\";\r\nimport { MemoryTransport } from \"../transport/memory.transport\";\r\nimport { BroadcastTransport } from \"../transport/broadcast.transport\";\r\nimport type { ITransport } from \"../transport/transport.interface\";\r\nimport type { Handler, EventMap, TabusMessage, FullEventMap, TabusOptions } from \"./types\";\r\n\r\nexport class Tabus<Events extends EventMap = EventMap> {\r\n /**\r\n * Unique identifier for this tab / instance.\r\n * Generated once per instance using `crypto.randomUUID()` or a manual fallback.\r\n *\r\n * @example\r\n * ```ts\r\n * const bus = new Tabus();\r\n * console.log(bus.tabId); // \"3f2a…\"\r\n * ```\r\n */\r\n readonly tabId: string;\r\n\r\n private readonly transport: ITransport;\r\n private readonly localHandlers = new Map<string, Set<Handler>>();\r\n private destroyed = false;\r\n private joinTimer: ReturnType<typeof setTimeout> | null = null;\r\n private readonly throttleMs: number;\r\n private lastEmitAt = 0;\r\n\r\n /**\r\n * Creates a new Tabus instance on the given channel.\r\n *\r\n * Uses `BroadcastChannel` when available (cross-tab communication); falls\r\n * back to an in-process in-memory bus otherwise.\r\n *\r\n * **`tab:join` is emitted asynchronously** (via `setTimeout 0`) so handlers\r\n * can be registered on the instance before the event is broadcast.\r\n *\r\n * @param channelName - Shared channel name (default: `\"tabus\"`). All\r\n * instances with the same channel name can communicate with each other.\r\n * @param options - Configuration options for this instance.\r\n * @param options.throttle - Minimum ms between emitted messages (default: 0, no throttle).\r\n *\r\n * @example\r\n * ```ts\r\n * type MyEvents = { data: { value: number }; reset: void };\r\n * const bus = new Tabus<MyEvents>(\"my-app\");\r\n * bus.on(\"tab:join\", ({ tabId }) => console.log(\"peer joined:\", tabId));\r\n * ```\r\n */\r\n constructor(channelName?: string, options?: TabusOptions) {\r\n this.tabId = generateId();\r\n this.throttleMs = options?.throttle ?? 0;\r\n const name = channelName ?? \"tabus\";\r\n\r\n this.transport = typeof BroadcastChannel !== \"undefined\" ? new BroadcastTransport(name) : new MemoryTransport(name);\r\n\r\n // Deduplication: ignore messages that originated from this instance.\r\n this.transport.onMessage((msg: TabusMessage) => {\r\n if (msg.tabId === this.tabId) return;\r\n this.dispatch(msg.event, msg.payload);\r\n });\r\n\r\n // Defer so callers can register tab:join handlers synchronously after\r\n // the constructor returns before the broadcast goes out.\r\n this.joinTimer = setTimeout(() => {\r\n this.joinTimer = null;\r\n if (!this.destroyed) {\r\n this.transport.send({\r\n tabId : this.tabId,\r\n event : \"tab:join\",\r\n payload: { tabId: this.tabId },\r\n });\r\n }\r\n }, 0);\r\n }\r\n\r\n /**\r\n * Subscribes to an event, including internal lifecycle events\r\n * `\"tab:join\"` and `\"tab:leave\"`.\r\n *\r\n * @param event - The event name to listen to.\r\n * @param handler - Callback invoked with the event payload when the event fires.\r\n * @returns `this` for chaining.\r\n *\r\n * @example\r\n * ```ts\r\n * bus\r\n * .on(\"data\", ({ value }) => console.log(value))\r\n * .on(\"tab:join\", ({ tabId }) => console.log(\"new peer:\", tabId));\r\n * ```\r\n */\r\n on<K extends keyof FullEventMap<Events>>(\r\n event: K,\r\n handler: Handler<FullEventMap<Events>[K]>,\r\n ): this {\r\n if (this.destroyed) return this;\r\n\r\n const key = String(event);\r\n\r\n if (!this.localHandlers.has(key)) {\r\n this.localHandlers.set(key, new Set());\r\n }\r\n\r\n this.localHandlers.get(key)!.add(handler as Handler);\r\n return this;\r\n }\r\n\r\n /**\r\n * Unsubscribes a previously registered handler.\r\n *\r\n * @param event - The event name.\r\n * @param handler - The exact same handler reference passed to `on()`.\r\n * @returns `this` for chaining.\r\n *\r\n * @example\r\n * ```ts\r\n * const handler = (p: { value: number }) => console.log(p.value);\r\n * bus.on(\"data\", handler);\r\n * // later:\r\n * bus.off(\"data\", handler);\r\n * ```\r\n */\r\n off<K extends keyof FullEventMap<Events>>(\r\n event: K,\r\n handler: Handler<FullEventMap<Events>[K]>,\r\n ): this {\r\n this.localHandlers.get(String(event))?.delete(handler as Handler);\r\n return this;\r\n }\r\n\r\n /**\r\n * Broadcasts an event to **all other instances** on the same channel.\r\n *\r\n * **The emitting instance does NOT receive its own event.** If local handling\r\n * is needed, call the handler directly after emitting.\r\n *\r\n * @param event - A user-defined event name (internal events cannot be emitted).\r\n * @param payload - The value to broadcast.\r\n * @returns `this` for chaining.\r\n *\r\n * @example\r\n * ```ts\r\n * bus.emit(\"data\", { value: 42 });\r\n * // other tabs receive { value: 42 }; this tab does not.\r\n * ```\r\n */\r\n emit<K extends keyof Events>(event: K, payload: Events[K]): this {\r\n if (this.destroyed) return this;\r\n\r\n if (this.throttleMs > 0) {\r\n const now = Date.now();\r\n if (now - this.lastEmitAt < this.throttleMs) return this;\r\n this.lastEmitAt = now;\r\n }\r\n\r\n this.transport.send({ tabId: this.tabId, event: String(event), payload });\r\n return this;\r\n }\r\n\r\n /**\r\n * Broadcasts `\"tab:leave\"`, closes the transport, and removes all handlers.\r\n * Safe to call multiple times — subsequent calls are no-ops.\r\n *\r\n * If the deferred `\"tab:join\"` has not yet fired (i.e., the instance is\r\n * destroyed within the same tick it was created), neither `\"tab:join\"` nor\r\n * `\"tab:leave\"` are sent to peers.\r\n *\r\n * @example\r\n * ```ts\r\n * window.addEventListener(\"beforeunload\", () => bus.destroy());\r\n * ```\r\n */\r\n destroy(): void {\r\n if (this.destroyed) return;\r\n this.destroyed = true;\r\n\r\n if (this.joinTimer !== null) {\r\n // tab:join hasn't fired yet — cancel it; peers don't know we existed,\r\n // so there is nothing to announce on departure either.\r\n clearTimeout(this.joinTimer);\r\n this.joinTimer = null;\r\n } else {\r\n // tab:join already fired — announce departure to peers.\r\n this.transport.send({\r\n tabId : this.tabId,\r\n event : \"tab:leave\",\r\n payload: { tabId: this.tabId },\r\n });\r\n }\r\n\r\n this.transport.destroy();\r\n this.localHandlers.clear();\r\n }\r\n\r\n /** Invokes all registered handlers for the given event with the payload. */\r\n private dispatch(event: string, payload: unknown): void {\r\n this.localHandlers.get(event)?.forEach((h) => h(payload));\r\n }\r\n}\r\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tabus-js",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Type-safe cross-tab message bus using BroadcastChannel, with in-memory fallback.",
5
5
  "author": "Rody Huancas <rodyhuancas.04@gmail.com>",
6
6
  "license": "MIT",