tabus-js 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/README.md ADDED
@@ -0,0 +1,104 @@
1
+ <div align="center">
2
+ <img src="./public/tabus-logo.png" alt="tabus" width="200" />
3
+ </div>
4
+
5
+ # tabus
6
+
7
+ > Type-safe cross-tab message bus for the browser, built on the native `BroadcastChannel` API.
8
+
9
+ [![npm](https://img.shields.io/npm/v/tabus)](https://www.npmjs.com/package/tabus)
10
+ [![license](https://img.shields.io/npm/l/tabus)](./LICENSE)
11
+ [![types](https://img.shields.io/npm/types/tabus)](./src/core/types.ts)
12
+
13
+ ## Why
14
+
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.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm install tabus-js
22
+ # or
23
+ pnpm add tabus-js
24
+ ```
25
+
26
+ ## Quick start
27
+
28
+ ```ts
29
+ import { Tabus } from "tabus-js";
30
+
31
+ type MyEvents = {
32
+ logout: { userId: number };
33
+ ping: { ts: number };
34
+ };
35
+
36
+ const bus = new Tabus<MyEvents>("my-app");
37
+
38
+ // Listen for events from other tabs
39
+ bus.on("logout", ({ userId }) => {
40
+ console.log("User logged out:", userId);
41
+ redirectToLogin();
42
+ });
43
+
44
+ // Broadcast to all other tabs
45
+ bus.emit("logout", { userId: 42 });
46
+
47
+ // Clean up
48
+ bus.destroy();
49
+ ```
50
+
51
+ ## API
52
+
53
+ ### `new Tabus<Events>(channelName?)`
54
+
55
+ Creates a new instance. All instances with the same `channelName` share a channel.
56
+ Defaults to `'tabus'` if no name is provided.
57
+
58
+ ### `bus.on(event, handler)`
59
+
60
+ Subscribes to an event. Returns `this` for chaining.
61
+ Also accepts internal events: `tab:join` and `tab:leave`.
62
+
63
+ ### `bus.emit(event, payload)`
64
+
65
+ Broadcasts an event to all other tabs on the same channel.
66
+ The emitting tab does **not** receive its own events.
67
+
68
+ ### `bus.off(event, handler)`
69
+
70
+ Removes a previously registered handler. Returns `this` for chaining.
71
+
72
+ ### `bus.destroy()`
73
+
74
+ Closes the channel, emits `tab:leave`, and removes all handlers. Safe to call multiple times.
75
+
76
+ ### `bus.tabId`
77
+
78
+ A unique UUID identifying this tab instance. Read-only.
79
+
80
+ ## Lifecycle events
81
+
82
+ ```ts
83
+ bus.on("tab:join", ({ tabId }) => console.log("Tab joined:", tabId));
84
+ bus.on("tab:leave", ({ tabId }) => console.log("Tab left:", tabId));
85
+ ```
86
+
87
+ These are emitted automatically — you cannot emit them manually.
88
+
89
+ ## Fallback
90
+
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.
92
+
93
+ ## Browser support
94
+
95
+ | Browser | Version |
96
+ | ------- | ------- |
97
+ | Chrome | 54+ |
98
+ | Firefox | 38+ |
99
+ | Safari | 15.4+ |
100
+ | Edge | 79+ |
101
+
102
+ ## License
103
+
104
+ MIT © [Rody Huancas](https://github.com/rody-huancas)
@@ -0,0 +1,139 @@
1
+ type Handler<T = unknown> = (payload: T) => void;
2
+ type EventMap = Record<string, unknown>;
3
+ interface TabusMessage<T = unknown> {
4
+ tabId: string;
5
+ event: string;
6
+ payload: T;
7
+ }
8
+ type InternalEvents = {
9
+ "tab:join": {
10
+ tabId: string;
11
+ };
12
+ "tab:leave": {
13
+ tabId: string;
14
+ };
15
+ };
16
+ /** Merges user-defined events with internal lifecycle events. */
17
+ type FullEventMap<T extends EventMap> = T & InternalEvents;
18
+
19
+ declare class Tabus<Events extends EventMap = EventMap> {
20
+ /**
21
+ * Unique identifier for this tab / instance.
22
+ * Generated once per instance using `crypto.randomUUID()` or a manual fallback.
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * const bus = new Tabus();
27
+ * console.log(bus.tabId); // "3f2a…"
28
+ * ```
29
+ */
30
+ readonly tabId: string;
31
+ private readonly transport;
32
+ private readonly localHandlers;
33
+ private destroyed;
34
+ private joinTimer;
35
+ /**
36
+ * Creates a new Tabus instance on the given channel.
37
+ *
38
+ * Uses `BroadcastChannel` when available (cross-tab communication); falls
39
+ * back to an in-process in-memory bus otherwise.
40
+ *
41
+ * **`tab:join` is emitted asynchronously** (via `setTimeout 0`) so handlers
42
+ * can be registered on the instance before the event is broadcast.
43
+ *
44
+ * @param channelName - Shared channel name (default: `"tabus"`). All
45
+ * instances with the same channel name can communicate with each other.
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * type MyEvents = { data: { value: number }; reset: void };
50
+ * const bus = new Tabus<MyEvents>("my-app");
51
+ * bus.on("tab:join", ({ tabId }) => console.log("peer joined:", tabId));
52
+ * ```
53
+ */
54
+ constructor(channelName?: string);
55
+ /**
56
+ * Subscribes to an event, including internal lifecycle events
57
+ * `"tab:join"` and `"tab:leave"`.
58
+ *
59
+ * @param event - The event name to listen to.
60
+ * @param handler - Callback invoked with the event payload when the event fires.
61
+ * @returns `this` for chaining.
62
+ *
63
+ * @example
64
+ * ```ts
65
+ * bus
66
+ * .on("data", ({ value }) => console.log(value))
67
+ * .on("tab:join", ({ tabId }) => console.log("new peer:", tabId));
68
+ * ```
69
+ */
70
+ on<K extends keyof FullEventMap<Events>>(event: K, handler: Handler<FullEventMap<Events>[K]>): this;
71
+ /**
72
+ * Unsubscribes a previously registered handler.
73
+ *
74
+ * @param event - The event name.
75
+ * @param handler - The exact same handler reference passed to `on()`.
76
+ * @returns `this` for chaining.
77
+ *
78
+ * @example
79
+ * ```ts
80
+ * const handler = (p: { value: number }) => console.log(p.value);
81
+ * bus.on("data", handler);
82
+ * // later:
83
+ * bus.off("data", handler);
84
+ * ```
85
+ */
86
+ off<K extends keyof FullEventMap<Events>>(event: K, handler: Handler<FullEventMap<Events>[K]>): this;
87
+ /**
88
+ * Broadcasts an event to **all other instances** on the same channel.
89
+ *
90
+ * **The emitting instance does NOT receive its own event.** If local handling
91
+ * is needed, call the handler directly after emitting.
92
+ *
93
+ * @param event - A user-defined event name (internal events cannot be emitted).
94
+ * @param payload - The value to broadcast.
95
+ * @returns `this` for chaining.
96
+ *
97
+ * @example
98
+ * ```ts
99
+ * bus.emit("data", { value: 42 });
100
+ * // other tabs receive { value: 42 }; this tab does not.
101
+ * ```
102
+ */
103
+ emit<K extends keyof Events>(event: K, payload: Events[K]): this;
104
+ /**
105
+ * Broadcasts `"tab:leave"`, closes the transport, and removes all handlers.
106
+ * Safe to call multiple times — subsequent calls are no-ops.
107
+ *
108
+ * If the deferred `"tab:join"` has not yet fired (i.e., the instance is
109
+ * destroyed within the same tick it was created), neither `"tab:join"` nor
110
+ * `"tab:leave"` are sent to peers.
111
+ *
112
+ * @example
113
+ * ```ts
114
+ * window.addEventListener("beforeunload", () => bus.destroy());
115
+ * ```
116
+ */
117
+ destroy(): void;
118
+ /** Invokes all registered handlers for the given event with the payload. */
119
+ private dispatch;
120
+ }
121
+
122
+ interface ITransport {
123
+ /**
124
+ * Sends a message to all listeners registered on the channel.
125
+ * @param msg - The message to broadcast.
126
+ */
127
+ send(msg: TabusMessage): void;
128
+ /**
129
+ * Registers an incoming-message listener.
130
+ * @param listener - Callback invoked with each incoming message.
131
+ */
132
+ onMessage(listener: (msg: TabusMessage) => void): void;
133
+ /**
134
+ * Tears down the transport and releases all held resources.
135
+ */
136
+ destroy(): void;
137
+ }
138
+
139
+ export { type EventMap, type Handler, type ITransport, type InternalEvents, Tabus, type TabusMessage };
@@ -0,0 +1,139 @@
1
+ type Handler<T = unknown> = (payload: T) => void;
2
+ type EventMap = Record<string, unknown>;
3
+ interface TabusMessage<T = unknown> {
4
+ tabId: string;
5
+ event: string;
6
+ payload: T;
7
+ }
8
+ type InternalEvents = {
9
+ "tab:join": {
10
+ tabId: string;
11
+ };
12
+ "tab:leave": {
13
+ tabId: string;
14
+ };
15
+ };
16
+ /** Merges user-defined events with internal lifecycle events. */
17
+ type FullEventMap<T extends EventMap> = T & InternalEvents;
18
+
19
+ declare class Tabus<Events extends EventMap = EventMap> {
20
+ /**
21
+ * Unique identifier for this tab / instance.
22
+ * Generated once per instance using `crypto.randomUUID()` or a manual fallback.
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * const bus = new Tabus();
27
+ * console.log(bus.tabId); // "3f2a…"
28
+ * ```
29
+ */
30
+ readonly tabId: string;
31
+ private readonly transport;
32
+ private readonly localHandlers;
33
+ private destroyed;
34
+ private joinTimer;
35
+ /**
36
+ * Creates a new Tabus instance on the given channel.
37
+ *
38
+ * Uses `BroadcastChannel` when available (cross-tab communication); falls
39
+ * back to an in-process in-memory bus otherwise.
40
+ *
41
+ * **`tab:join` is emitted asynchronously** (via `setTimeout 0`) so handlers
42
+ * can be registered on the instance before the event is broadcast.
43
+ *
44
+ * @param channelName - Shared channel name (default: `"tabus"`). All
45
+ * instances with the same channel name can communicate with each other.
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * type MyEvents = { data: { value: number }; reset: void };
50
+ * const bus = new Tabus<MyEvents>("my-app");
51
+ * bus.on("tab:join", ({ tabId }) => console.log("peer joined:", tabId));
52
+ * ```
53
+ */
54
+ constructor(channelName?: string);
55
+ /**
56
+ * Subscribes to an event, including internal lifecycle events
57
+ * `"tab:join"` and `"tab:leave"`.
58
+ *
59
+ * @param event - The event name to listen to.
60
+ * @param handler - Callback invoked with the event payload when the event fires.
61
+ * @returns `this` for chaining.
62
+ *
63
+ * @example
64
+ * ```ts
65
+ * bus
66
+ * .on("data", ({ value }) => console.log(value))
67
+ * .on("tab:join", ({ tabId }) => console.log("new peer:", tabId));
68
+ * ```
69
+ */
70
+ on<K extends keyof FullEventMap<Events>>(event: K, handler: Handler<FullEventMap<Events>[K]>): this;
71
+ /**
72
+ * Unsubscribes a previously registered handler.
73
+ *
74
+ * @param event - The event name.
75
+ * @param handler - The exact same handler reference passed to `on()`.
76
+ * @returns `this` for chaining.
77
+ *
78
+ * @example
79
+ * ```ts
80
+ * const handler = (p: { value: number }) => console.log(p.value);
81
+ * bus.on("data", handler);
82
+ * // later:
83
+ * bus.off("data", handler);
84
+ * ```
85
+ */
86
+ off<K extends keyof FullEventMap<Events>>(event: K, handler: Handler<FullEventMap<Events>[K]>): this;
87
+ /**
88
+ * Broadcasts an event to **all other instances** on the same channel.
89
+ *
90
+ * **The emitting instance does NOT receive its own event.** If local handling
91
+ * is needed, call the handler directly after emitting.
92
+ *
93
+ * @param event - A user-defined event name (internal events cannot be emitted).
94
+ * @param payload - The value to broadcast.
95
+ * @returns `this` for chaining.
96
+ *
97
+ * @example
98
+ * ```ts
99
+ * bus.emit("data", { value: 42 });
100
+ * // other tabs receive { value: 42 }; this tab does not.
101
+ * ```
102
+ */
103
+ emit<K extends keyof Events>(event: K, payload: Events[K]): this;
104
+ /**
105
+ * Broadcasts `"tab:leave"`, closes the transport, and removes all handlers.
106
+ * Safe to call multiple times — subsequent calls are no-ops.
107
+ *
108
+ * If the deferred `"tab:join"` has not yet fired (i.e., the instance is
109
+ * destroyed within the same tick it was created), neither `"tab:join"` nor
110
+ * `"tab:leave"` are sent to peers.
111
+ *
112
+ * @example
113
+ * ```ts
114
+ * window.addEventListener("beforeunload", () => bus.destroy());
115
+ * ```
116
+ */
117
+ destroy(): void;
118
+ /** Invokes all registered handlers for the given event with the payload. */
119
+ private dispatch;
120
+ }
121
+
122
+ interface ITransport {
123
+ /**
124
+ * Sends a message to all listeners registered on the channel.
125
+ * @param msg - The message to broadcast.
126
+ */
127
+ send(msg: TabusMessage): void;
128
+ /**
129
+ * Registers an incoming-message listener.
130
+ * @param listener - Callback invoked with each incoming message.
131
+ */
132
+ onMessage(listener: (msg: TabusMessage) => void): void;
133
+ /**
134
+ * Tears down the transport and releases all held resources.
135
+ */
136
+ destroy(): void;
137
+ }
138
+
139
+ export { type EventMap, type Handler, type ITransport, type InternalEvents, Tabus, type TabusMessage };
package/dist/index.js ADDED
@@ -0,0 +1,237 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ Tabus: () => Tabus
24
+ });
25
+ module.exports = __toCommonJS(index_exports);
26
+
27
+ // src/core/generate-id.ts
28
+ var generateId = () => {
29
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
30
+ return crypto.randomUUID();
31
+ }
32
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
33
+ const r = Math.random() * 16 | 0;
34
+ return (c === "x" ? r : r & 3 | 8).toString(16);
35
+ });
36
+ };
37
+
38
+ // src/transport/memory.transport.ts
39
+ var channels = /* @__PURE__ */ new Map();
40
+ var warnedChannels = /* @__PURE__ */ new Set();
41
+ var MemoryTransport = class {
42
+ constructor(channelName) {
43
+ this.listener = null;
44
+ this.channelName = channelName;
45
+ if (!channels.has(channelName)) {
46
+ channels.set(channelName, /* @__PURE__ */ new Set());
47
+ }
48
+ if (!warnedChannels.has(channelName)) {
49
+ console.warn(
50
+ `[tabus] BroadcastChannel is not available. Falling back to in-memory bus (channel: "${channelName}").`
51
+ );
52
+ warnedChannels.add(channelName);
53
+ }
54
+ }
55
+ send(msg) {
56
+ channels.get(this.channelName)?.forEach((l) => l(msg));
57
+ }
58
+ onMessage(listener) {
59
+ if (this.listener) {
60
+ channels.get(this.channelName)?.delete(this.listener);
61
+ }
62
+ this.listener = listener;
63
+ channels.get(this.channelName)?.add(listener);
64
+ }
65
+ destroy() {
66
+ if (this.listener) {
67
+ channels.get(this.channelName)?.delete(this.listener);
68
+ this.listener = null;
69
+ }
70
+ }
71
+ };
72
+
73
+ // src/transport/broadcast.transport.ts
74
+ var BroadcastTransport = class {
75
+ constructor(channelName) {
76
+ this.bc = new BroadcastChannel(channelName);
77
+ }
78
+ send(msg) {
79
+ this.bc.postMessage(msg);
80
+ }
81
+ onMessage(listener) {
82
+ this.bc.addEventListener(
83
+ "message",
84
+ (ev) => listener(ev.data)
85
+ );
86
+ }
87
+ destroy() {
88
+ this.bc.close();
89
+ }
90
+ };
91
+
92
+ // src/core/tabus.ts
93
+ var Tabus = class {
94
+ /**
95
+ * Creates a new Tabus instance on the given channel.
96
+ *
97
+ * Uses `BroadcastChannel` when available (cross-tab communication); falls
98
+ * back to an in-process in-memory bus otherwise.
99
+ *
100
+ * **`tab:join` is emitted asynchronously** (via `setTimeout 0`) so handlers
101
+ * can be registered on the instance before the event is broadcast.
102
+ *
103
+ * @param channelName - Shared channel name (default: `"tabus"`). All
104
+ * instances with the same channel name can communicate with each other.
105
+ *
106
+ * @example
107
+ * ```ts
108
+ * type MyEvents = { data: { value: number }; reset: void };
109
+ * const bus = new Tabus<MyEvents>("my-app");
110
+ * bus.on("tab:join", ({ tabId }) => console.log("peer joined:", tabId));
111
+ * ```
112
+ */
113
+ constructor(channelName) {
114
+ this.localHandlers = /* @__PURE__ */ new Map();
115
+ this.destroyed = false;
116
+ this.joinTimer = null;
117
+ this.tabId = generateId();
118
+ const name = channelName ?? "tabus";
119
+ this.transport = typeof BroadcastChannel !== "undefined" ? new BroadcastTransport(name) : new MemoryTransport(name);
120
+ this.transport.onMessage((msg) => {
121
+ if (msg.tabId === this.tabId) return;
122
+ this.dispatch(msg.event, msg.payload);
123
+ });
124
+ this.joinTimer = setTimeout(() => {
125
+ this.joinTimer = null;
126
+ if (!this.destroyed) {
127
+ this.transport.send({
128
+ tabId: this.tabId,
129
+ event: "tab:join",
130
+ payload: { tabId: this.tabId }
131
+ });
132
+ }
133
+ }, 0);
134
+ }
135
+ /**
136
+ * Subscribes to an event, including internal lifecycle events
137
+ * `"tab:join"` and `"tab:leave"`.
138
+ *
139
+ * @param event - The event name to listen to.
140
+ * @param handler - Callback invoked with the event payload when the event fires.
141
+ * @returns `this` for chaining.
142
+ *
143
+ * @example
144
+ * ```ts
145
+ * bus
146
+ * .on("data", ({ value }) => console.log(value))
147
+ * .on("tab:join", ({ tabId }) => console.log("new peer:", tabId));
148
+ * ```
149
+ */
150
+ on(event, handler) {
151
+ if (this.destroyed) return this;
152
+ const key = String(event);
153
+ if (!this.localHandlers.has(key)) {
154
+ this.localHandlers.set(key, /* @__PURE__ */ new Set());
155
+ }
156
+ this.localHandlers.get(key).add(handler);
157
+ return this;
158
+ }
159
+ /**
160
+ * Unsubscribes a previously registered handler.
161
+ *
162
+ * @param event - The event name.
163
+ * @param handler - The exact same handler reference passed to `on()`.
164
+ * @returns `this` for chaining.
165
+ *
166
+ * @example
167
+ * ```ts
168
+ * const handler = (p: { value: number }) => console.log(p.value);
169
+ * bus.on("data", handler);
170
+ * // later:
171
+ * bus.off("data", handler);
172
+ * ```
173
+ */
174
+ off(event, handler) {
175
+ this.localHandlers.get(String(event))?.delete(handler);
176
+ return this;
177
+ }
178
+ /**
179
+ * Broadcasts an event to **all other instances** on the same channel.
180
+ *
181
+ * **The emitting instance does NOT receive its own event.** If local handling
182
+ * is needed, call the handler directly after emitting.
183
+ *
184
+ * @param event - A user-defined event name (internal events cannot be emitted).
185
+ * @param payload - The value to broadcast.
186
+ * @returns `this` for chaining.
187
+ *
188
+ * @example
189
+ * ```ts
190
+ * bus.emit("data", { value: 42 });
191
+ * // other tabs receive { value: 42 }; this tab does not.
192
+ * ```
193
+ */
194
+ emit(event, payload) {
195
+ if (this.destroyed) return this;
196
+ this.transport.send({ tabId: this.tabId, event: String(event), payload });
197
+ return this;
198
+ }
199
+ /**
200
+ * Broadcasts `"tab:leave"`, closes the transport, and removes all handlers.
201
+ * Safe to call multiple times — subsequent calls are no-ops.
202
+ *
203
+ * If the deferred `"tab:join"` has not yet fired (i.e., the instance is
204
+ * destroyed within the same tick it was created), neither `"tab:join"` nor
205
+ * `"tab:leave"` are sent to peers.
206
+ *
207
+ * @example
208
+ * ```ts
209
+ * window.addEventListener("beforeunload", () => bus.destroy());
210
+ * ```
211
+ */
212
+ destroy() {
213
+ if (this.destroyed) return;
214
+ this.destroyed = true;
215
+ if (this.joinTimer !== null) {
216
+ clearTimeout(this.joinTimer);
217
+ this.joinTimer = null;
218
+ } else {
219
+ this.transport.send({
220
+ tabId: this.tabId,
221
+ event: "tab:leave",
222
+ payload: { tabId: this.tabId }
223
+ });
224
+ }
225
+ this.transport.destroy();
226
+ this.localHandlers.clear();
227
+ }
228
+ /** Invokes all registered handlers for the given event with the payload. */
229
+ dispatch(event, payload) {
230
+ this.localHandlers.get(event)?.forEach((h) => h(payload));
231
+ }
232
+ };
233
+ // Annotate the CommonJS export names for ESM import in node:
234
+ 0 && (module.exports = {
235
+ Tabus
236
+ });
237
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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":"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","names":[]}
package/dist/index.mjs ADDED
@@ -0,0 +1,210 @@
1
+ // src/core/generate-id.ts
2
+ var generateId = () => {
3
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
4
+ return crypto.randomUUID();
5
+ }
6
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
7
+ const r = Math.random() * 16 | 0;
8
+ return (c === "x" ? r : r & 3 | 8).toString(16);
9
+ });
10
+ };
11
+
12
+ // src/transport/memory.transport.ts
13
+ var channels = /* @__PURE__ */ new Map();
14
+ var warnedChannels = /* @__PURE__ */ new Set();
15
+ var MemoryTransport = class {
16
+ constructor(channelName) {
17
+ this.listener = null;
18
+ this.channelName = channelName;
19
+ if (!channels.has(channelName)) {
20
+ channels.set(channelName, /* @__PURE__ */ new Set());
21
+ }
22
+ if (!warnedChannels.has(channelName)) {
23
+ console.warn(
24
+ `[tabus] BroadcastChannel is not available. Falling back to in-memory bus (channel: "${channelName}").`
25
+ );
26
+ warnedChannels.add(channelName);
27
+ }
28
+ }
29
+ send(msg) {
30
+ channels.get(this.channelName)?.forEach((l) => l(msg));
31
+ }
32
+ onMessage(listener) {
33
+ if (this.listener) {
34
+ channels.get(this.channelName)?.delete(this.listener);
35
+ }
36
+ this.listener = listener;
37
+ channels.get(this.channelName)?.add(listener);
38
+ }
39
+ destroy() {
40
+ if (this.listener) {
41
+ channels.get(this.channelName)?.delete(this.listener);
42
+ this.listener = null;
43
+ }
44
+ }
45
+ };
46
+
47
+ // src/transport/broadcast.transport.ts
48
+ var BroadcastTransport = class {
49
+ constructor(channelName) {
50
+ this.bc = new BroadcastChannel(channelName);
51
+ }
52
+ send(msg) {
53
+ this.bc.postMessage(msg);
54
+ }
55
+ onMessage(listener) {
56
+ this.bc.addEventListener(
57
+ "message",
58
+ (ev) => listener(ev.data)
59
+ );
60
+ }
61
+ destroy() {
62
+ this.bc.close();
63
+ }
64
+ };
65
+
66
+ // src/core/tabus.ts
67
+ var Tabus = class {
68
+ /**
69
+ * Creates a new Tabus instance on the given channel.
70
+ *
71
+ * Uses `BroadcastChannel` when available (cross-tab communication); falls
72
+ * back to an in-process in-memory bus otherwise.
73
+ *
74
+ * **`tab:join` is emitted asynchronously** (via `setTimeout 0`) so handlers
75
+ * can be registered on the instance before the event is broadcast.
76
+ *
77
+ * @param channelName - Shared channel name (default: `"tabus"`). All
78
+ * instances with the same channel name can communicate with each other.
79
+ *
80
+ * @example
81
+ * ```ts
82
+ * type MyEvents = { data: { value: number }; reset: void };
83
+ * const bus = new Tabus<MyEvents>("my-app");
84
+ * bus.on("tab:join", ({ tabId }) => console.log("peer joined:", tabId));
85
+ * ```
86
+ */
87
+ constructor(channelName) {
88
+ this.localHandlers = /* @__PURE__ */ new Map();
89
+ this.destroyed = false;
90
+ this.joinTimer = null;
91
+ this.tabId = generateId();
92
+ const name = channelName ?? "tabus";
93
+ this.transport = typeof BroadcastChannel !== "undefined" ? new BroadcastTransport(name) : new MemoryTransport(name);
94
+ this.transport.onMessage((msg) => {
95
+ if (msg.tabId === this.tabId) return;
96
+ this.dispatch(msg.event, msg.payload);
97
+ });
98
+ this.joinTimer = setTimeout(() => {
99
+ this.joinTimer = null;
100
+ if (!this.destroyed) {
101
+ this.transport.send({
102
+ tabId: this.tabId,
103
+ event: "tab:join",
104
+ payload: { tabId: this.tabId }
105
+ });
106
+ }
107
+ }, 0);
108
+ }
109
+ /**
110
+ * Subscribes to an event, including internal lifecycle events
111
+ * `"tab:join"` and `"tab:leave"`.
112
+ *
113
+ * @param event - The event name to listen to.
114
+ * @param handler - Callback invoked with the event payload when the event fires.
115
+ * @returns `this` for chaining.
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * bus
120
+ * .on("data", ({ value }) => console.log(value))
121
+ * .on("tab:join", ({ tabId }) => console.log("new peer:", tabId));
122
+ * ```
123
+ */
124
+ on(event, handler) {
125
+ if (this.destroyed) return this;
126
+ const key = String(event);
127
+ if (!this.localHandlers.has(key)) {
128
+ this.localHandlers.set(key, /* @__PURE__ */ new Set());
129
+ }
130
+ this.localHandlers.get(key).add(handler);
131
+ return this;
132
+ }
133
+ /**
134
+ * Unsubscribes a previously registered handler.
135
+ *
136
+ * @param event - The event name.
137
+ * @param handler - The exact same handler reference passed to `on()`.
138
+ * @returns `this` for chaining.
139
+ *
140
+ * @example
141
+ * ```ts
142
+ * const handler = (p: { value: number }) => console.log(p.value);
143
+ * bus.on("data", handler);
144
+ * // later:
145
+ * bus.off("data", handler);
146
+ * ```
147
+ */
148
+ off(event, handler) {
149
+ this.localHandlers.get(String(event))?.delete(handler);
150
+ return this;
151
+ }
152
+ /**
153
+ * Broadcasts an event to **all other instances** on the same channel.
154
+ *
155
+ * **The emitting instance does NOT receive its own event.** If local handling
156
+ * is needed, call the handler directly after emitting.
157
+ *
158
+ * @param event - A user-defined event name (internal events cannot be emitted).
159
+ * @param payload - The value to broadcast.
160
+ * @returns `this` for chaining.
161
+ *
162
+ * @example
163
+ * ```ts
164
+ * bus.emit("data", { value: 42 });
165
+ * // other tabs receive { value: 42 }; this tab does not.
166
+ * ```
167
+ */
168
+ emit(event, payload) {
169
+ if (this.destroyed) return this;
170
+ this.transport.send({ tabId: this.tabId, event: String(event), payload });
171
+ return this;
172
+ }
173
+ /**
174
+ * Broadcasts `"tab:leave"`, closes the transport, and removes all handlers.
175
+ * Safe to call multiple times — subsequent calls are no-ops.
176
+ *
177
+ * If the deferred `"tab:join"` has not yet fired (i.e., the instance is
178
+ * destroyed within the same tick it was created), neither `"tab:join"` nor
179
+ * `"tab:leave"` are sent to peers.
180
+ *
181
+ * @example
182
+ * ```ts
183
+ * window.addEventListener("beforeunload", () => bus.destroy());
184
+ * ```
185
+ */
186
+ destroy() {
187
+ if (this.destroyed) return;
188
+ this.destroyed = true;
189
+ if (this.joinTimer !== null) {
190
+ clearTimeout(this.joinTimer);
191
+ this.joinTimer = null;
192
+ } else {
193
+ this.transport.send({
194
+ tabId: this.tabId,
195
+ event: "tab:leave",
196
+ payload: { tabId: this.tabId }
197
+ });
198
+ }
199
+ this.transport.destroy();
200
+ this.localHandlers.clear();
201
+ }
202
+ /** Invokes all registered handlers for the given event with the payload. */
203
+ dispatch(event, payload) {
204
+ this.localHandlers.get(event)?.forEach((h) => h(payload));
205
+ }
206
+ };
207
+ export {
208
+ Tabus
209
+ };
210
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +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":";AAKO,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":[]}
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "tabus-js",
3
+ "version": "0.1.0",
4
+ "description": "Type-safe cross-tab message bus using BroadcastChannel, with in-memory fallback.",
5
+ "author": "Rody Huancas <rodyhuancas.04@gmail.com>",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/rody-huancas/tabus#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/rody-huancas/tabus.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/rody-huancas/tabus/issues"
14
+ },
15
+ "keywords": [
16
+ "broadcast",
17
+ "tab",
18
+ "cross-tab",
19
+ "BroadcastChannel",
20
+ "sync",
21
+ "message-bus",
22
+ "typescript"
23
+ ],
24
+ "main": "./dist/index.js",
25
+ "module": "./dist/index.mjs",
26
+ "types": "./dist/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.mjs",
31
+ "require": "./dist/index.js"
32
+ }
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "README.md",
37
+ "CHANGELOG.md"
38
+ ],
39
+ "scripts": {
40
+ "build": "tsup",
41
+ "dev": "tsup --watch",
42
+ "test": "vitest run",
43
+ "test:watch": "vitest",
44
+ "lint": "tsc --noEmit",
45
+ "prepublishOnly": "pnpm run lint && pnpm run test && pnpm run build"
46
+ },
47
+ "devDependencies": {
48
+ "typescript": "^5.4.0",
49
+ "tsup": "^8.0.0",
50
+ "vitest": "^3.0.0"
51
+ },
52
+ "engines": {
53
+ "node": ">=18"
54
+ },
55
+ "sideEffects": false
56
+ }