purrtabby 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 minseon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,436 @@
1
+ # purrtabby
2
+
3
+ <p align="center">
4
+ <img src="./logo.png" width="100" height="100" alt="purrtabby" style="border-radius: 22px;">
5
+ <br>
6
+ <a href="https://www.npmjs.org/package/purrtabby"><img src="https://img.shields.io/npm/v/purrtabby.svg" alt="npm"></a>
7
+ <img src="https://github.com/let-sunny/purrtabby/workflows/CI/badge.svg" alt="build status">
8
+ <a href="https://unpkg.com/purrtabby/dist/index.global.js"><img src="https://img.badgesize.io/https://unpkg.com/purrtabby/dist/index.global.js?compression=gzip" alt="gzip size"></a>
9
+ </p>
10
+
11
+ > Lightweight browser tab communication and leader election library using BroadcastChannel and localStorage
12
+
13
+ A lightweight library for cross-tab communication and leader election in browser environments. Perfect for coordinating multiple tabs/windows and electing a single leader tab.
14
+
15
+ ## Highlights
16
+
17
+ **Microscopic**: weighs less than 6KB minified (~2KB gzipped)
18
+
19
+ **Reliable**: leader election with lease-based heartbeat mechanism
20
+
21
+ **Modern**: async iterables for generator-based message streams
22
+
23
+ **Type-Safe**: full TypeScript support
24
+
25
+ **Zero Dependencies**: uses native Browser APIs only (BroadcastChannel, localStorage)
26
+
27
+ **Flexible**: callback-based or generator-based APIs, your choice
28
+
29
+ ## Features
30
+
31
+ - **Cross-tab communication** using BroadcastChannel
32
+ - **Leader election** with localStorage-based lease and heartbeat
33
+ - **Generator-based streams** for async iteration
34
+ - **TypeScript** support
35
+ - **Browser compatible** (requires BroadcastChannel and localStorage support)
36
+ - **Zero dependencies** (uses native Browser APIs)
37
+ - **Tiny bundle size**
38
+ - **AbortSignal** support for stream cancellation
39
+
40
+ ## Table of Contents
41
+
42
+ - [Installation](#installation)
43
+ - [Requirements](#requirements)
44
+ - [Usage](#usage)
45
+ - [TabBus - Cross-tab Communication](#tabbus---cross-tab-communication)
46
+ - [LeaderElector - Leader Election](#leaderelector---leader-election)
47
+ - [Generator-based Streams](#generator-based-streams)
48
+ - [With AbortSignal](#with-abortsignal)
49
+ - [Callback-based API](#callback-based-api)
50
+ - [API](#api)
51
+ - [createBus(options)](#createbusoptions)
52
+ - [createLeaderElector(options)](#createleaderelectoroptions)
53
+ - [Examples](#examples)
54
+
55
+ ## Installation
56
+
57
+ **npm:**
58
+ ```bash
59
+ npm install purrtabby
60
+ ```
61
+
62
+ **UMD build (via unpkg):**
63
+ ```html
64
+ <script src="https://unpkg.com/purrtabby/dist/index.global.js"></script>
65
+ ```
66
+
67
+ ### Requirements
68
+
69
+ **Runtime:**
70
+ - **Browser**: Modern browsers with BroadcastChannel and localStorage support
71
+ - **Node.js**: Not supported (requires browser APIs)
72
+
73
+ **Development:**
74
+ - **Node.js**: 24.13.0 (tested with this version)
75
+
76
+ ## Usage
77
+
78
+ ### TabBus - Cross-tab Communication
79
+
80
+ ```typescript
81
+ import { createBus } from 'purrtabby';
82
+
83
+ const bus = createBus({
84
+ channel: 'my-app-channel',
85
+ });
86
+
87
+ // Subscribe to specific message types
88
+ const unsubscribe = bus.subscribe('user-action', (message) => {
89
+ console.log('Received:', message.payload);
90
+ console.log('From tab:', message.tabId);
91
+ });
92
+
93
+ // Publish messages to all tabs
94
+ bus.publish('user-action', { action: 'click', target: 'button' });
95
+
96
+ // Subscribe to all messages
97
+ bus.subscribeAll((message) => {
98
+ console.log('Any message:', message.type, message.payload);
99
+ });
100
+
101
+ // Cleanup
102
+ unsubscribe();
103
+ bus.close();
104
+ ```
105
+
106
+ ### LeaderElector - Leader Election
107
+
108
+ ```typescript
109
+ import { createLeaderElector } from 'purrtabby';
110
+
111
+ const leader = createLeaderElector({
112
+ key: 'my-app-leader',
113
+ tabId: 'tab-1', // Optional: auto-generated if not provided
114
+ leaseMs: 5000, // Lease duration in milliseconds
115
+ heartbeatMs: 2000, // Heartbeat interval
116
+ jitterMs: 500, // Jitter to avoid thundering herd
117
+ });
118
+
119
+ // Start leader election
120
+ leader.start();
121
+
122
+ // Check if this tab is the leader
123
+ if (leader.isLeader()) {
124
+ console.log('This tab is the leader!');
125
+ }
126
+
127
+ // Listen for leader events
128
+ leader.on('acquired', () => {
129
+ console.log('Became the leader!');
130
+ });
131
+
132
+ leader.on('lost', (event) => {
133
+ console.log('Lost leadership:', event.meta?.newLeader);
134
+ });
135
+
136
+ leader.on('changed', (event) => {
137
+ console.log('Leadership changed to:', event.meta?.newLeader);
138
+ });
139
+
140
+ // Stop leader election
141
+ leader.stop();
142
+ ```
143
+
144
+ ### Generator-based Streams
145
+
146
+ ```typescript
147
+ import { createBus, createLeaderElector } from 'purrtabby';
148
+
149
+ const bus = createBus({ channel: 'my-channel' });
150
+
151
+ // Consume messages as async iterable
152
+ (async () => {
153
+ for await (const message of bus.stream()) {
154
+ console.log('Received:', message.type, message.payload);
155
+ }
156
+ })();
157
+
158
+ const leader = createLeaderElector({
159
+ key: 'my-leader',
160
+ tabId: 'tab-1',
161
+ });
162
+
163
+ leader.start();
164
+
165
+ // Consume leader events as async iterable
166
+ (async () => {
167
+ for await (const event of leader.stream()) {
168
+ console.log('Leader event:', event.type, event.ts);
169
+ }
170
+ })();
171
+ ```
172
+
173
+ ### With AbortSignal
174
+
175
+ ```typescript
176
+ const controller = new AbortController();
177
+
178
+ (async () => {
179
+ for await (const message of bus.stream({ signal: controller.signal })) {
180
+ console.log(message);
181
+ if (shouldStop) {
182
+ controller.abort();
183
+ }
184
+ }
185
+ })();
186
+ ```
187
+
188
+ ### Callback-based API
189
+
190
+ ```typescript
191
+ const bus = createBus({ channel: 'my-channel' });
192
+
193
+ // Subscribe to messages
194
+ const unsubscribeMessage = bus.subscribe('type', (message) => {
195
+ console.log('Message:', message);
196
+ });
197
+
198
+ // Subscribe to all messages
199
+ const unsubscribeAll = bus.subscribeAll((message) => {
200
+ console.log('Any message:', message);
201
+ });
202
+
203
+ // Unsubscribe when done
204
+ unsubscribeMessage();
205
+ unsubscribeAll();
206
+
207
+ const leader = createLeaderElector({
208
+ key: 'my-leader',
209
+ tabId: 'tab-1',
210
+ });
211
+
212
+ leader.start();
213
+
214
+ // Subscribe to leader events
215
+ const unsubscribeAcquired = leader.on('acquired', (event) => {
216
+ console.log('Became leader');
217
+ });
218
+
219
+ const unsubscribeLost = leader.on('lost', (event) => {
220
+ console.log('Lost leadership');
221
+ });
222
+
223
+ // Subscribe to all leader events
224
+ const unsubscribeAll = leader.onAll((event) => {
225
+ console.log('Leader event:', event.type);
226
+ });
227
+
228
+ // Unsubscribe when done
229
+ unsubscribeAcquired();
230
+ unsubscribeLost();
231
+ unsubscribeAll();
232
+ ```
233
+
234
+ ## API
235
+
236
+ ### `createBus(options)`
237
+
238
+ Creates a new TabBus instance for cross-tab communication.
239
+
240
+ #### Options
241
+
242
+ | Option | Type | Default | Description |
243
+ |--------|------|---------|-------------|
244
+ | `channel` | `string` | **required** | BroadcastChannel name |
245
+ | `tabId` | `string` | auto-generated | Unique tab identifier |
246
+
247
+ #### TabBus Methods
248
+
249
+ ##### `publish(type, payload?)`
250
+
251
+ Publishes a message to all tabs listening on the same channel.
252
+
253
+ ```typescript
254
+ bus.publish('user-action', { action: 'click' });
255
+ ```
256
+
257
+ ##### `subscribe(type, handler)`
258
+
259
+ Subscribes to messages of a specific type. Returns an unsubscribe function.
260
+
261
+ ```typescript
262
+ const unsubscribe = bus.subscribe('user-action', (message) => {
263
+ console.log(message);
264
+ });
265
+ ```
266
+
267
+ ##### `subscribeAll(handler)`
268
+
269
+ Subscribes to all messages regardless of type. Returns an unsubscribe function.
270
+
271
+ ```typescript
272
+ const unsubscribe = bus.subscribeAll((message) => {
273
+ console.log(message);
274
+ });
275
+ ```
276
+
277
+ ##### `stream(options?)`
278
+
279
+ Returns an async iterable of all messages.
280
+
281
+ ```typescript
282
+ for await (const message of bus.stream({ signal: abortSignal })) {
283
+ console.log(message);
284
+ }
285
+ ```
286
+
287
+ ##### `getTabId()`
288
+
289
+ Returns the current tab's unique identifier.
290
+
291
+ ##### `close()`
292
+
293
+ Closes the bus and cleans up resources.
294
+
295
+ ### `createLeaderElector(options)`
296
+
297
+ Creates a new LeaderElector instance for leader election.
298
+
299
+ #### Options
300
+
301
+ | Option | Type | Default | Description |
302
+ |--------|------|---------|-------------|
303
+ | `key` | `string` | **required** | localStorage key for leader lease |
304
+ | `tabId` | `string` | **required** | Unique tab identifier |
305
+ | `leaseMs` | `number` | `5000` | Lease duration in milliseconds |
306
+ | `heartbeatMs` | `number` | `2000` | Heartbeat interval in milliseconds |
307
+ | `jitterMs` | `number` | `500` | Jitter range to avoid synchronization |
308
+
309
+ #### LeaderElector Methods
310
+
311
+ ##### `start()`
312
+
313
+ Starts the leader election process.
314
+
315
+ ##### `stop()`
316
+
317
+ Stops leader election and releases leadership if held.
318
+
319
+ ##### `isLeader()`
320
+
321
+ Returns `true` if this tab is currently the leader.
322
+
323
+ ##### `on(event, handler)`
324
+
325
+ Subscribes to a specific leader event. Returns an unsubscribe function.
326
+
327
+ Events:
328
+ - `'acquired'` - This tab became the leader
329
+ - `'lost'` - This tab lost leadership
330
+ - `'changed'` - Leadership changed to another tab
331
+
332
+ ##### `onAll(handler)`
333
+
334
+ Subscribes to all leader events. Returns an unsubscribe function.
335
+
336
+ ##### `stream(options?)`
337
+
338
+ Returns an async iterable of leader events.
339
+
340
+ ##### `getTabId()`
341
+
342
+ Returns the current tab's unique identifier.
343
+
344
+ ## Examples
345
+
346
+ ### Complete Example: Tab Coordination
347
+
348
+ ```typescript
349
+ import { createBus, createLeaderElector } from 'purrtabby';
350
+
351
+ // Set up cross-tab communication
352
+ const bus = createBus({ channel: 'my-app' });
353
+
354
+ // Set up leader election
355
+ const leader = createLeaderElector({
356
+ key: 'my-app-leader',
357
+ tabId: `tab-${Date.now()}`,
358
+ leaseMs: 5000,
359
+ heartbeatMs: 2000,
360
+ });
361
+
362
+ // Start leader election
363
+ leader.start();
364
+
365
+ // Handle leader events
366
+ leader.on('acquired', () => {
367
+ console.log('This tab is now the leader');
368
+ // Only the leader performs certain tasks
369
+ bus.publish('leader-announcement', { tabId: leader.getTabId() });
370
+ });
371
+
372
+ leader.on('lost', () => {
373
+ console.log('This tab is no longer the leader');
374
+ });
375
+
376
+ // Listen for messages from other tabs
377
+ bus.subscribe('user-action', (message) => {
378
+ console.log('User action from tab:', message.tabId, message.payload);
379
+
380
+ // If we're the leader, process the action
381
+ if (leader.isLeader()) {
382
+ console.log('Processing action as leader');
383
+ }
384
+ });
385
+
386
+ // Publish user actions
387
+ bus.publish('user-action', { action: 'click', target: 'button' });
388
+
389
+ // Cleanup
390
+ leader.stop();
391
+ bus.close();
392
+ ```
393
+
394
+ ### Using with purrcat (WebSocket)
395
+
396
+ ```typescript
397
+ import createSocket from 'purrcat';
398
+ import { createBus, createLeaderElector } from 'purrtabby';
399
+
400
+ const bus = createBus({ channel: 'ws-coordinator' });
401
+ const leader = createLeaderElector({
402
+ key: 'ws-leader',
403
+ tabId: `tab-${Date.now()}`,
404
+ });
405
+
406
+ leader.start();
407
+
408
+ // Only the leader maintains WebSocket connection
409
+ leader.on('acquired', () => {
410
+ const socket = createSocket({
411
+ url: 'wss://api.example.com/ws',
412
+ });
413
+
414
+ // Forward messages from WebSocket to all tabs
415
+ socket.onMessage((message) => {
416
+ bus.publish('ws-message', message);
417
+ });
418
+
419
+ // Forward messages from tabs to WebSocket
420
+ bus.subscribe('send-to-ws', (message) => {
421
+ socket.send(message.payload);
422
+ });
423
+ });
424
+
425
+ // All tabs can receive WebSocket messages
426
+ bus.subscribe('ws-message', (message) => {
427
+ console.log('WebSocket message:', message.payload);
428
+ });
429
+
430
+ // Any tab can send to WebSocket (leader forwards it)
431
+ bus.publish('send-to-ws', { type: 'ping' });
432
+ ```
433
+
434
+ ## License
435
+
436
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1 @@
1
+ "use strict";var T=Object.defineProperty;var j=Object.getOwnPropertyDescriptor;var O=Object.getOwnPropertyNames;var $=Object.prototype.hasOwnProperty;var G=(e,r)=>{for(var a in r)T(e,a,{get:r[a],enumerable:!0})},J=(e,r,a,n)=>{if(r&&typeof r=="object"||typeof r=="function")for(let s of O(r))!$.call(e,s)&&s!==a&&T(e,s,{get:()=>r[s],enumerable:!(n=j(r,s))||n.enumerable});return e};var N=e=>J(T({},"__esModule",{value:!0}),e);var U={};G(U,{createBus:()=>L,createLeaderElector:()=>g,createRPC:()=>I,default:()=>K});module.exports=N(U);function C(){return`${Date.now()}-${Math.random().toString(36).substring(2,11)}`}function w(){return`${Date.now()}-${Math.random().toString(36).substring(2,11)}`}function k(e,r){return{type:e,ts:Date.now(),meta:r}}function p(e,r){return{type:e,ts:Date.now(),meta:r}}function y(e,r){let a=(Math.random()*2-1)*r;return Math.max(0,e+a)}function m(e){try{let r=localStorage.getItem(e);return r?JSON.parse(r):null}catch{return null}}function h(e,r){try{localStorage.setItem(e,JSON.stringify(r))}catch(a){console.error("Error writing leader lease:",a)}}function B(e){try{localStorage.removeItem(e)}catch(r){console.error("Error removing leader lease:",r)}}function v(e){return e?Date.now()-e.timestamp<e.leaseMs:!1}function E(e,r,a,n,s){return new Promise(o=>{let t=null,c=!1,l=()=>{c||(e&&e.removeEventListener("abort",i),s(d),t!==null&&(clearTimeout(t),t=null))},d=()=>{c||(c=!0,l(),o())},i=()=>d();if(e?.aborted){d();return}if(e&&e.addEventListener("abort",i),r()){d();return}if(n(d),!e){let u=()=>{if(!c){if(r()){d();return}t=setTimeout(u,100)}};u()}})}async function*S(e,r,a){e.activeIterators++;try{for(;!a?.aborted;){for(;r.messages.length>0&&!a?.aborted;)yield r.messages.shift();await E(a,()=>r.messages.length>0,e.messageResolvers,n=>e.messageResolvers.add(n),n=>e.messageResolvers.delete(n))}}finally{e.activeIterators--,e.activeIterators===0&&(r.messages=[])}}async function*M(e,r,a){e.activeIterators++;try{for(;!a?.aborted;){for(;r.events.length>0&&!a?.aborted;)yield r.events.shift();await E(a,()=>r.events.length>0,e.eventResolvers,n=>e.eventResolvers.add(n),n=>e.eventResolvers.delete(n))}}finally{e.activeIterators--,e.activeIterators===0&&(r.events=[])}}function P(e,r,a,n){let s=a.messageCallbacks.get(e.type);s&&s.forEach(o=>{try{o(e)}catch(t){console.error("Error in TabBus callback:",t)}}),a.allCallbacks.forEach(o=>{try{o(e)}catch(t){console.error("Error in TabBus all callback:",t)}}),n.push(e),a.messageResolvers.forEach(o=>o()),a.messageResolvers.clear()}function F(e,r,a,n){try{let s=e.data;P(s,r,a,n)}catch(s){console.error("Error handling TabBus message:",s)}}function L(e){let{channel:r,tabId:a}=e,n=a||C();if(typeof BroadcastChannel>"u")throw new Error("BroadcastChannel is not supported in this environment");let s=new BroadcastChannel(r),o={channel:s,tabId:n,messageCallbacks:new Map,allCallbacks:new Set,messageResolvers:new Set,activeIterators:0},t=[];return s.onmessage=l=>{F(l,n,o,t)},s.onmessageerror=()=>{let l=k("err",{error:"Failed to receive message"})},{publish(l,d){let i={type:l,payload:d,tabId:n,ts:Date.now()};s.postMessage(i),setTimeout(()=>{P(i,n,o,t)},0)},subscribe(l,d){return o.messageCallbacks.has(l)||o.messageCallbacks.set(l,new Set),o.messageCallbacks.get(l).add(d),()=>{let i=o.messageCallbacks.get(l);i&&(i.delete(d),i.size===0&&o.messageCallbacks.delete(l))}},subscribeAll(l){return o.allCallbacks.add(l),()=>{o.allCallbacks.delete(l)}},stream(l){return S(o,{messages:t},l?.signal)},getTabId(){return n},close(){s.close(),o.channel=null,o.messageCallbacks.clear(),o.allCallbacks.clear(),o.messageResolvers.clear()}}}function b(e,r,a){let n=r.eventCallbacks.get(e.type);n&&n.forEach(s=>{try{s(e)}catch(o){console.error("Error in LeaderElector callback:",o)}}),r.allCallbacks.forEach(s=>{try{s(e)}catch(o){console.error("Error in LeaderElector all callback:",o)}}),a.push(e),r.eventResolvers.forEach(s=>s()),r.eventResolvers.clear()}function z(e,r){if(e.stopped)return!1;let a=m(e.key);if(!v(a)){let n={tabId:e.tabId,timestamp:Date.now(),leaseMs:e.leaseMs};if(h(e.key,n),m(e.key)?.tabId===e.tabId)return e.isLeader||(e.isLeader=!0,b(p("acquire",{tabId:e.tabId}),e,r)),!0}return a?.tabId===e.tabId&&v(a)?(e.isLeader||(e.isLeader=!0,b(p("acquire",{tabId:e.tabId}),e,r)),!0):(e.isLeader&&(e.isLeader=!1,b(p("lose",{tabId:e.tabId,newLeader:a?.tabId}),e,r)),!1)}function H(e,r){if(e.stopped||!e.isLeader)return;let a=m(e.key);if(a?.tabId===e.tabId){let n={...a,timestamp:Date.now()};h(e.key,n)}else e.isLeader&&(e.isLeader=!1,b(p("lose",{tabId:e.tabId}),e,r))}function x(e,r){if(e.stopped)return;let a=m(e.key),n=e.isLeader,s=a?.tabId===e.tabId&&v(a);!n&&s?(e.isLeader=!0,b(p("acquire",{tabId:e.tabId}),e,r)):n&&!s?(e.isLeader=!1,b(p("lose",{tabId:e.tabId,newLeader:a?.tabId}),e,r)):n&&s&&a?.tabId!==e.tabId&&b(p("change",{tabId:e.tabId,newLeader:a.tabId}),e,r)}function g(e){let{key:r,tabId:a,leaseMs:n=5e3,heartbeatMs:s=2e3,jitterMs:o=500}=e;if(typeof localStorage>"u")throw new Error("localStorage is not supported in this environment");let t={key:r,tabId:a,leaseMs:n,heartbeatMs:s,jitterMs:o,isLeader:!1,heartbeatTimer:null,checkTimer:null,eventCallbacks:new Map,allCallbacks:new Set,eventResolvers:new Set,activeIterators:0,stopped:!1},c=[];function l(i){i.key!==t.key||i.storageArea!==localStorage||x(t,c)}return typeof window<"u"&&window.addEventListener("storage",l),{start(){if(t.stopped&&(t.stopped=!1),z(t,c),t.isLeader){let u=y(t.heartbeatMs,t.jitterMs);t.heartbeatTimer=setInterval(()=>{H(t,c)},u)}let i=y(t.heartbeatMs/2,t.jitterMs);t.checkTimer=setInterval(()=>{x(t,c)},i)},stop(){t.stopped=!0,t.heartbeatTimer&&(clearInterval(t.heartbeatTimer),t.heartbeatTimer=null),t.checkTimer&&(clearInterval(t.checkTimer),t.checkTimer=null),t.isLeader&&(m(t.key)?.tabId===t.tabId&&B(t.key),t.isLeader=!1,b(p("lose",{tabId:t.tabId,reason:"stopped"}),t,c)),typeof window<"u"&&window.removeEventListener("storage",l)},isLeader(){return t.isLeader},on(i,u){return t.eventCallbacks.has(i)||t.eventCallbacks.set(i,new Set),t.eventCallbacks.get(i).add(u),()=>{let f=t.eventCallbacks.get(i);f&&(f.delete(u),f.size===0&&t.eventCallbacks.delete(i))}},onAll(i){return t.allCallbacks.add(i),()=>{t.allCallbacks.delete(i)}},stream(i){return M(t,{events:c},i?.signal)},getTabId(){return t.tabId}}}function Q(e,r,a){if(e.tabId===a)return;let n=r.handlers.get(e.method);if(!n){let s={type:"rpc-response",requestId:e.requestId,error:`No handler found for method: ${e.method}`,tabId:a,ts:Date.now()};try{r.bus.publish("rpc-response",s)}catch{}return}Promise.resolve(n(e.params)).then(s=>{let o={type:"rpc-response",requestId:e.requestId,result:s,tabId:a,ts:Date.now()};try{r.bus.publish("rpc-response",o)}catch{}}).catch(s=>{let o={type:"rpc-response",requestId:e.requestId,error:s instanceof Error?s.message:String(s),tabId:a,ts:Date.now()};try{r.bus.publish("rpc-response",o)}catch{}})}function V(e,r,a){let n=r.pendingRequests.get(e.requestId);n&&(clearTimeout(n.timeout),r.pendingRequests.delete(e.requestId),e.error?n.reject(new Error(e.error)):n.resolve(e.result))}function I(e){let{bus:r,timeout:a=5e3}=e,n=r.getTabId(),s={bus:r,timeout:a,pendingRequests:new Map,handlers:new Map},o=r.subscribe("rpc-request",l=>{let d=l.payload;d&&d.type==="rpc-request"&&Q(d,s,n)}),t=r.subscribe("rpc-response",l=>{let d=l.payload;d&&d.type==="rpc-response"&&V(d,s,n)});return{call(l,d,i){let u=w(),f=i?.timeout??s.timeout;return new Promise((q,R)=>{let A=setTimeout(()=>{s.pendingRequests.delete(u),R(new Error(`RPC call timeout: ${l} (${f}ms)`))},f);s.pendingRequests.set(u,{resolve:q,reject:R,timeout:A});let D={type:"rpc-request",method:l,params:d,requestId:u,tabId:n,ts:Date.now()};r.publish("rpc-request",D)})},handle(l,d){return s.handlers.set(l,d),()=>{s.handlers.delete(l)}},close(){s.pendingRequests.forEach(l=>{clearTimeout(l.timeout),l.reject(new Error("RPC closed"))}),s.pendingRequests.clear(),s.handlers.clear(),o(),t()}}}var K={createBus:L,createLeaderElector:g,createRPC:I};0&&(module.exports={createBus,createLeaderElector,createRPC});
@@ -0,0 +1,135 @@
1
+ /** TabBus message structure */
2
+ interface TabBusMessage<T = any> {
3
+ type: string;
4
+ payload?: T;
5
+ tabId: string;
6
+ ts: number;
7
+ }
8
+ /** TabBus event types */
9
+ type TabBusEventType = 'msg' | 'err';
10
+ /** TabBus event structure */
11
+ interface TabBusEvent {
12
+ type: TabBusEventType;
13
+ ts: number;
14
+ meta?: Record<string, any>;
15
+ }
16
+ /** Options for createBus() */
17
+ interface BusOptions {
18
+ channel: string;
19
+ tabId?: string;
20
+ }
21
+ /** Return type of createBus(), provides async iterables and callbacks */
22
+ interface TabBus<T = any> {
23
+ /** Publish a message to all tabs */
24
+ publish(type: string, payload?: T): void;
25
+ /** Subscribe to messages of a specific type */
26
+ subscribe(type: string, handler: (message: TabBusMessage<T>) => void): () => void;
27
+ /** Subscribe to all messages */
28
+ subscribeAll(handler: (message: TabBusMessage<T>) => void): () => void;
29
+ /** Get async iterable stream of messages */
30
+ stream(options?: {
31
+ signal?: AbortSignal;
32
+ }): AsyncIterable<TabBusMessage<T>>;
33
+ /** Get the current tab ID */
34
+ getTabId(): string;
35
+ /** Close the bus and cleanup */
36
+ close(): void;
37
+ }
38
+ /** Leader election event types */
39
+ type LeaderEventType = 'acquire' | 'lose' | 'change';
40
+ /** Leader election event structure */
41
+ interface LeaderEvent {
42
+ type: LeaderEventType;
43
+ ts: number;
44
+ meta?: Record<string, any>;
45
+ }
46
+ /** Options for createLeaderElector() */
47
+ interface LeaderElectorOptions {
48
+ key: string;
49
+ tabId: string;
50
+ leaseMs?: number;
51
+ heartbeatMs?: number;
52
+ jitterMs?: number;
53
+ }
54
+ /** Return type of createLeaderElector() */
55
+ interface LeaderElector {
56
+ /** Start leader election */
57
+ start(): void;
58
+ /** Stop leader election and release leadership if held */
59
+ stop(): void;
60
+ /** Check if this tab is the leader */
61
+ isLeader(): boolean;
62
+ /** Subscribe to leader events */
63
+ on(event: LeaderEventType, handler: (event: LeaderEvent) => void): () => void;
64
+ /** Subscribe to all leader events */
65
+ onAll(handler: (event: LeaderEvent) => void): () => void;
66
+ /** Get async iterable stream of leader events */
67
+ stream(options?: {
68
+ signal?: AbortSignal;
69
+ }): AsyncIterable<LeaderEvent>;
70
+ /** Get the current tab ID */
71
+ getTabId(): string;
72
+ }
73
+ /** RPC request message */
74
+ interface RPCRequest<T = any> {
75
+ type: 'rpc-request';
76
+ method: string;
77
+ params?: T;
78
+ requestId: string;
79
+ tabId: string;
80
+ ts: number;
81
+ }
82
+ /** RPC response message */
83
+ interface RPCResponse<T = any> {
84
+ type: 'rpc-response';
85
+ requestId: string;
86
+ result?: T;
87
+ error?: string;
88
+ tabId: string;
89
+ ts: number;
90
+ }
91
+ /** Options for createRPC() */
92
+ interface RPCOptions {
93
+ bus: TabBus;
94
+ timeout?: number;
95
+ }
96
+ /** Return type of createRPC() */
97
+ interface RPC {
98
+ /** Call a method on the leader (or any tab) */
99
+ call<TParams = any, TResult = any>(method: string, params?: TParams, options?: {
100
+ timeout?: number;
101
+ }): Promise<TResult>;
102
+ /** Handle incoming RPC requests */
103
+ handle<TParams = any, TResult = any>(method: string, handler: (params?: TParams) => Promise<TResult> | TResult): () => void;
104
+ /** Close the RPC instance and cleanup */
105
+ close(): void;
106
+ }
107
+
108
+ /**
109
+ * Create a TabBus instance for cross-tab communication
110
+ * @param options - Bus configuration options
111
+ * @returns TabBus instance
112
+ */
113
+ declare function createBus<T = any>(options: BusOptions): TabBus<T>;
114
+
115
+ /**
116
+ * Create a LeaderElector instance for leader election
117
+ * @param options - Leader elector configuration options
118
+ * @returns LeaderElector instance
119
+ */
120
+ declare function createLeaderElector(options: LeaderElectorOptions): LeaderElector;
121
+
122
+ /**
123
+ * Create an RPC instance for request-response communication
124
+ * @param options - RPC configuration options
125
+ * @returns RPC instance
126
+ */
127
+ declare function createRPC(options: RPCOptions): RPC;
128
+
129
+ declare const _default: {
130
+ createBus: typeof createBus;
131
+ createLeaderElector: typeof createLeaderElector;
132
+ createRPC: typeof createRPC;
133
+ };
134
+
135
+ export { type BusOptions, createBus as CreateBus, createLeaderElector as CreateLeaderElector, createRPC as CreateRPC, type LeaderElector, type LeaderElectorOptions, type LeaderEvent, type LeaderEventType, type RPC, type RPCOptions, type RPCRequest, type RPCResponse, type TabBus, type TabBusEvent, type TabBusEventType, type TabBusMessage, createBus, createLeaderElector, createRPC, _default as default };
@@ -0,0 +1,135 @@
1
+ /** TabBus message structure */
2
+ interface TabBusMessage<T = any> {
3
+ type: string;
4
+ payload?: T;
5
+ tabId: string;
6
+ ts: number;
7
+ }
8
+ /** TabBus event types */
9
+ type TabBusEventType = 'msg' | 'err';
10
+ /** TabBus event structure */
11
+ interface TabBusEvent {
12
+ type: TabBusEventType;
13
+ ts: number;
14
+ meta?: Record<string, any>;
15
+ }
16
+ /** Options for createBus() */
17
+ interface BusOptions {
18
+ channel: string;
19
+ tabId?: string;
20
+ }
21
+ /** Return type of createBus(), provides async iterables and callbacks */
22
+ interface TabBus<T = any> {
23
+ /** Publish a message to all tabs */
24
+ publish(type: string, payload?: T): void;
25
+ /** Subscribe to messages of a specific type */
26
+ subscribe(type: string, handler: (message: TabBusMessage<T>) => void): () => void;
27
+ /** Subscribe to all messages */
28
+ subscribeAll(handler: (message: TabBusMessage<T>) => void): () => void;
29
+ /** Get async iterable stream of messages */
30
+ stream(options?: {
31
+ signal?: AbortSignal;
32
+ }): AsyncIterable<TabBusMessage<T>>;
33
+ /** Get the current tab ID */
34
+ getTabId(): string;
35
+ /** Close the bus and cleanup */
36
+ close(): void;
37
+ }
38
+ /** Leader election event types */
39
+ type LeaderEventType = 'acquire' | 'lose' | 'change';
40
+ /** Leader election event structure */
41
+ interface LeaderEvent {
42
+ type: LeaderEventType;
43
+ ts: number;
44
+ meta?: Record<string, any>;
45
+ }
46
+ /** Options for createLeaderElector() */
47
+ interface LeaderElectorOptions {
48
+ key: string;
49
+ tabId: string;
50
+ leaseMs?: number;
51
+ heartbeatMs?: number;
52
+ jitterMs?: number;
53
+ }
54
+ /** Return type of createLeaderElector() */
55
+ interface LeaderElector {
56
+ /** Start leader election */
57
+ start(): void;
58
+ /** Stop leader election and release leadership if held */
59
+ stop(): void;
60
+ /** Check if this tab is the leader */
61
+ isLeader(): boolean;
62
+ /** Subscribe to leader events */
63
+ on(event: LeaderEventType, handler: (event: LeaderEvent) => void): () => void;
64
+ /** Subscribe to all leader events */
65
+ onAll(handler: (event: LeaderEvent) => void): () => void;
66
+ /** Get async iterable stream of leader events */
67
+ stream(options?: {
68
+ signal?: AbortSignal;
69
+ }): AsyncIterable<LeaderEvent>;
70
+ /** Get the current tab ID */
71
+ getTabId(): string;
72
+ }
73
+ /** RPC request message */
74
+ interface RPCRequest<T = any> {
75
+ type: 'rpc-request';
76
+ method: string;
77
+ params?: T;
78
+ requestId: string;
79
+ tabId: string;
80
+ ts: number;
81
+ }
82
+ /** RPC response message */
83
+ interface RPCResponse<T = any> {
84
+ type: 'rpc-response';
85
+ requestId: string;
86
+ result?: T;
87
+ error?: string;
88
+ tabId: string;
89
+ ts: number;
90
+ }
91
+ /** Options for createRPC() */
92
+ interface RPCOptions {
93
+ bus: TabBus;
94
+ timeout?: number;
95
+ }
96
+ /** Return type of createRPC() */
97
+ interface RPC {
98
+ /** Call a method on the leader (or any tab) */
99
+ call<TParams = any, TResult = any>(method: string, params?: TParams, options?: {
100
+ timeout?: number;
101
+ }): Promise<TResult>;
102
+ /** Handle incoming RPC requests */
103
+ handle<TParams = any, TResult = any>(method: string, handler: (params?: TParams) => Promise<TResult> | TResult): () => void;
104
+ /** Close the RPC instance and cleanup */
105
+ close(): void;
106
+ }
107
+
108
+ /**
109
+ * Create a TabBus instance for cross-tab communication
110
+ * @param options - Bus configuration options
111
+ * @returns TabBus instance
112
+ */
113
+ declare function createBus<T = any>(options: BusOptions): TabBus<T>;
114
+
115
+ /**
116
+ * Create a LeaderElector instance for leader election
117
+ * @param options - Leader elector configuration options
118
+ * @returns LeaderElector instance
119
+ */
120
+ declare function createLeaderElector(options: LeaderElectorOptions): LeaderElector;
121
+
122
+ /**
123
+ * Create an RPC instance for request-response communication
124
+ * @param options - RPC configuration options
125
+ * @returns RPC instance
126
+ */
127
+ declare function createRPC(options: RPCOptions): RPC;
128
+
129
+ declare const _default: {
130
+ createBus: typeof createBus;
131
+ createLeaderElector: typeof createLeaderElector;
132
+ createRPC: typeof createRPC;
133
+ };
134
+
135
+ export { type BusOptions, createBus as CreateBus, createLeaderElector as CreateLeaderElector, createRPC as CreateRPC, type LeaderElector, type LeaderElectorOptions, type LeaderEvent, type LeaderEventType, type RPC, type RPCOptions, type RPCRequest, type RPCResponse, type TabBus, type TabBusEvent, type TabBusEventType, type TabBusMessage, createBus, createLeaderElector, createRPC, _default as default };
@@ -0,0 +1 @@
1
+ "use strict";var purrtabby=(()=>{var T=Object.defineProperty;var j=Object.getOwnPropertyDescriptor;var O=Object.getOwnPropertyNames;var $=Object.prototype.hasOwnProperty;var G=(e,r)=>{for(var a in r)T(e,a,{get:r[a],enumerable:!0})},J=(e,r,a,n)=>{if(r&&typeof r=="object"||typeof r=="function")for(let s of O(r))!$.call(e,s)&&s!==a&&T(e,s,{get:()=>r[s],enumerable:!(n=j(r,s))||n.enumerable});return e};var N=e=>J(T({},"__esModule",{value:!0}),e);var U={};G(U,{createBus:()=>L,createLeaderElector:()=>g,createRPC:()=>I,default:()=>K});function C(){return`${Date.now()}-${Math.random().toString(36).substring(2,11)}`}function w(){return`${Date.now()}-${Math.random().toString(36).substring(2,11)}`}function k(e,r){return{type:e,ts:Date.now(),meta:r}}function p(e,r){return{type:e,ts:Date.now(),meta:r}}function y(e,r){let a=(Math.random()*2-1)*r;return Math.max(0,e+a)}function m(e){try{let r=localStorage.getItem(e);return r?JSON.parse(r):null}catch{return null}}function h(e,r){try{localStorage.setItem(e,JSON.stringify(r))}catch(a){console.error("Error writing leader lease:",a)}}function B(e){try{localStorage.removeItem(e)}catch(r){console.error("Error removing leader lease:",r)}}function v(e){return e?Date.now()-e.timestamp<e.leaseMs:!1}function E(e,r,a,n,s){return new Promise(o=>{let t=null,c=!1,l=()=>{c||(e&&e.removeEventListener("abort",i),s(d),t!==null&&(clearTimeout(t),t=null))},d=()=>{c||(c=!0,l(),o())},i=()=>d();if(e?.aborted){d();return}if(e&&e.addEventListener("abort",i),r()){d();return}if(n(d),!e){let u=()=>{if(!c){if(r()){d();return}t=setTimeout(u,100)}};u()}})}async function*S(e,r,a){e.activeIterators++;try{for(;!a?.aborted;){for(;r.messages.length>0&&!a?.aborted;)yield r.messages.shift();await E(a,()=>r.messages.length>0,e.messageResolvers,n=>e.messageResolvers.add(n),n=>e.messageResolvers.delete(n))}}finally{e.activeIterators--,e.activeIterators===0&&(r.messages=[])}}async function*M(e,r,a){e.activeIterators++;try{for(;!a?.aborted;){for(;r.events.length>0&&!a?.aborted;)yield r.events.shift();await E(a,()=>r.events.length>0,e.eventResolvers,n=>e.eventResolvers.add(n),n=>e.eventResolvers.delete(n))}}finally{e.activeIterators--,e.activeIterators===0&&(r.events=[])}}function P(e,r,a,n){let s=a.messageCallbacks.get(e.type);s&&s.forEach(o=>{try{o(e)}catch(t){console.error("Error in TabBus callback:",t)}}),a.allCallbacks.forEach(o=>{try{o(e)}catch(t){console.error("Error in TabBus all callback:",t)}}),n.push(e),a.messageResolvers.forEach(o=>o()),a.messageResolvers.clear()}function F(e,r,a,n){try{let s=e.data;P(s,r,a,n)}catch(s){console.error("Error handling TabBus message:",s)}}function L(e){let{channel:r,tabId:a}=e,n=a||C();if(typeof BroadcastChannel>"u")throw new Error("BroadcastChannel is not supported in this environment");let s=new BroadcastChannel(r),o={channel:s,tabId:n,messageCallbacks:new Map,allCallbacks:new Set,messageResolvers:new Set,activeIterators:0},t=[];return s.onmessage=l=>{F(l,n,o,t)},s.onmessageerror=()=>{let l=k("err",{error:"Failed to receive message"})},{publish(l,d){let i={type:l,payload:d,tabId:n,ts:Date.now()};s.postMessage(i),setTimeout(()=>{P(i,n,o,t)},0)},subscribe(l,d){return o.messageCallbacks.has(l)||o.messageCallbacks.set(l,new Set),o.messageCallbacks.get(l).add(d),()=>{let i=o.messageCallbacks.get(l);i&&(i.delete(d),i.size===0&&o.messageCallbacks.delete(l))}},subscribeAll(l){return o.allCallbacks.add(l),()=>{o.allCallbacks.delete(l)}},stream(l){return S(o,{messages:t},l?.signal)},getTabId(){return n},close(){s.close(),o.channel=null,o.messageCallbacks.clear(),o.allCallbacks.clear(),o.messageResolvers.clear()}}}function b(e,r,a){let n=r.eventCallbacks.get(e.type);n&&n.forEach(s=>{try{s(e)}catch(o){console.error("Error in LeaderElector callback:",o)}}),r.allCallbacks.forEach(s=>{try{s(e)}catch(o){console.error("Error in LeaderElector all callback:",o)}}),a.push(e),r.eventResolvers.forEach(s=>s()),r.eventResolvers.clear()}function z(e,r){if(e.stopped)return!1;let a=m(e.key);if(!v(a)){let n={tabId:e.tabId,timestamp:Date.now(),leaseMs:e.leaseMs};if(h(e.key,n),m(e.key)?.tabId===e.tabId)return e.isLeader||(e.isLeader=!0,b(p("acquire",{tabId:e.tabId}),e,r)),!0}return a?.tabId===e.tabId&&v(a)?(e.isLeader||(e.isLeader=!0,b(p("acquire",{tabId:e.tabId}),e,r)),!0):(e.isLeader&&(e.isLeader=!1,b(p("lose",{tabId:e.tabId,newLeader:a?.tabId}),e,r)),!1)}function H(e,r){if(e.stopped||!e.isLeader)return;let a=m(e.key);if(a?.tabId===e.tabId){let n={...a,timestamp:Date.now()};h(e.key,n)}else e.isLeader&&(e.isLeader=!1,b(p("lose",{tabId:e.tabId}),e,r))}function x(e,r){if(e.stopped)return;let a=m(e.key),n=e.isLeader,s=a?.tabId===e.tabId&&v(a);!n&&s?(e.isLeader=!0,b(p("acquire",{tabId:e.tabId}),e,r)):n&&!s?(e.isLeader=!1,b(p("lose",{tabId:e.tabId,newLeader:a?.tabId}),e,r)):n&&s&&a?.tabId!==e.tabId&&b(p("change",{tabId:e.tabId,newLeader:a.tabId}),e,r)}function g(e){let{key:r,tabId:a,leaseMs:n=5e3,heartbeatMs:s=2e3,jitterMs:o=500}=e;if(typeof localStorage>"u")throw new Error("localStorage is not supported in this environment");let t={key:r,tabId:a,leaseMs:n,heartbeatMs:s,jitterMs:o,isLeader:!1,heartbeatTimer:null,checkTimer:null,eventCallbacks:new Map,allCallbacks:new Set,eventResolvers:new Set,activeIterators:0,stopped:!1},c=[];function l(i){i.key!==t.key||i.storageArea!==localStorage||x(t,c)}return typeof window<"u"&&window.addEventListener("storage",l),{start(){if(t.stopped&&(t.stopped=!1),z(t,c),t.isLeader){let u=y(t.heartbeatMs,t.jitterMs);t.heartbeatTimer=setInterval(()=>{H(t,c)},u)}let i=y(t.heartbeatMs/2,t.jitterMs);t.checkTimer=setInterval(()=>{x(t,c)},i)},stop(){t.stopped=!0,t.heartbeatTimer&&(clearInterval(t.heartbeatTimer),t.heartbeatTimer=null),t.checkTimer&&(clearInterval(t.checkTimer),t.checkTimer=null),t.isLeader&&(m(t.key)?.tabId===t.tabId&&B(t.key),t.isLeader=!1,b(p("lose",{tabId:t.tabId,reason:"stopped"}),t,c)),typeof window<"u"&&window.removeEventListener("storage",l)},isLeader(){return t.isLeader},on(i,u){return t.eventCallbacks.has(i)||t.eventCallbacks.set(i,new Set),t.eventCallbacks.get(i).add(u),()=>{let f=t.eventCallbacks.get(i);f&&(f.delete(u),f.size===0&&t.eventCallbacks.delete(i))}},onAll(i){return t.allCallbacks.add(i),()=>{t.allCallbacks.delete(i)}},stream(i){return M(t,{events:c},i?.signal)},getTabId(){return t.tabId}}}function Q(e,r,a){if(e.tabId===a)return;let n=r.handlers.get(e.method);if(!n){let s={type:"rpc-response",requestId:e.requestId,error:`No handler found for method: ${e.method}`,tabId:a,ts:Date.now()};try{r.bus.publish("rpc-response",s)}catch{}return}Promise.resolve(n(e.params)).then(s=>{let o={type:"rpc-response",requestId:e.requestId,result:s,tabId:a,ts:Date.now()};try{r.bus.publish("rpc-response",o)}catch{}}).catch(s=>{let o={type:"rpc-response",requestId:e.requestId,error:s instanceof Error?s.message:String(s),tabId:a,ts:Date.now()};try{r.bus.publish("rpc-response",o)}catch{}})}function V(e,r,a){let n=r.pendingRequests.get(e.requestId);n&&(clearTimeout(n.timeout),r.pendingRequests.delete(e.requestId),e.error?n.reject(new Error(e.error)):n.resolve(e.result))}function I(e){let{bus:r,timeout:a=5e3}=e,n=r.getTabId(),s={bus:r,timeout:a,pendingRequests:new Map,handlers:new Map},o=r.subscribe("rpc-request",l=>{let d=l.payload;d&&d.type==="rpc-request"&&Q(d,s,n)}),t=r.subscribe("rpc-response",l=>{let d=l.payload;d&&d.type==="rpc-response"&&V(d,s,n)});return{call(l,d,i){let u=w(),f=i?.timeout??s.timeout;return new Promise((q,R)=>{let A=setTimeout(()=>{s.pendingRequests.delete(u),R(new Error(`RPC call timeout: ${l} (${f}ms)`))},f);s.pendingRequests.set(u,{resolve:q,reject:R,timeout:A});let D={type:"rpc-request",method:l,params:d,requestId:u,tabId:n,ts:Date.now()};r.publish("rpc-request",D)})},handle(l,d){return s.handlers.set(l,d),()=>{s.handlers.delete(l)}},close(){s.pendingRequests.forEach(l=>{clearTimeout(l.timeout),l.reject(new Error("RPC closed"))}),s.pendingRequests.clear(),s.handlers.clear(),o(),t()}}}var K={createBus:L,createLeaderElector:g,createRPC:I};return N(U);})();
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ function R(){return`${Date.now()}-${Math.random().toString(36).substring(2,11)}`}function C(){return`${Date.now()}-${Math.random().toString(36).substring(2,11)}`}function w(e,r){return{type:e,ts:Date.now(),meta:r}}function p(e,r){return{type:e,ts:Date.now(),meta:r}}function L(e,r){let a=(Math.random()*2-1)*r;return Math.max(0,e+a)}function m(e){try{let r=localStorage.getItem(e);return r?JSON.parse(r):null}catch{return null}}function g(e,r){try{localStorage.setItem(e,JSON.stringify(r))}catch(a){console.error("Error writing leader lease:",a)}}function k(e){try{localStorage.removeItem(e)}catch(r){console.error("Error removing leader lease:",r)}}function v(e){return e?Date.now()-e.timestamp<e.leaseMs:!1}function I(e,r,a,n,s){return new Promise(o=>{let t=null,c=!1,l=()=>{c||(e&&e.removeEventListener("abort",i),s(d),t!==null&&(clearTimeout(t),t=null))},d=()=>{c||(c=!0,l(),o())},i=()=>d();if(e?.aborted){d();return}if(e&&e.addEventListener("abort",i),r()){d();return}if(n(d),!e){let u=()=>{if(!c){if(r()){d();return}t=setTimeout(u,100)}};u()}})}async function*B(e,r,a){e.activeIterators++;try{for(;!a?.aborted;){for(;r.messages.length>0&&!a?.aborted;)yield r.messages.shift();await I(a,()=>r.messages.length>0,e.messageResolvers,n=>e.messageResolvers.add(n),n=>e.messageResolvers.delete(n))}}finally{e.activeIterators--,e.activeIterators===0&&(r.messages=[])}}async function*S(e,r,a){e.activeIterators++;try{for(;!a?.aborted;){for(;r.events.length>0&&!a?.aborted;)yield r.events.shift();await I(a,()=>r.events.length>0,e.eventResolvers,n=>e.eventResolvers.add(n),n=>e.eventResolvers.delete(n))}}finally{e.activeIterators--,e.activeIterators===0&&(r.events=[])}}function M(e,r,a,n){let s=a.messageCallbacks.get(e.type);s&&s.forEach(o=>{try{o(e)}catch(t){console.error("Error in TabBus callback:",t)}}),a.allCallbacks.forEach(o=>{try{o(e)}catch(t){console.error("Error in TabBus all callback:",t)}}),n.push(e),a.messageResolvers.forEach(o=>o()),a.messageResolvers.clear()}function D(e,r,a,n){try{let s=e.data;M(s,r,a,n)}catch(s){console.error("Error handling TabBus message:",s)}}function T(e){let{channel:r,tabId:a}=e,n=a||R();if(typeof BroadcastChannel>"u")throw new Error("BroadcastChannel is not supported in this environment");let s=new BroadcastChannel(r),o={channel:s,tabId:n,messageCallbacks:new Map,allCallbacks:new Set,messageResolvers:new Set,activeIterators:0},t=[];return s.onmessage=l=>{D(l,n,o,t)},s.onmessageerror=()=>{let l=w("err",{error:"Failed to receive message"})},{publish(l,d){let i={type:l,payload:d,tabId:n,ts:Date.now()};s.postMessage(i),setTimeout(()=>{M(i,n,o,t)},0)},subscribe(l,d){return o.messageCallbacks.has(l)||o.messageCallbacks.set(l,new Set),o.messageCallbacks.get(l).add(d),()=>{let i=o.messageCallbacks.get(l);i&&(i.delete(d),i.size===0&&o.messageCallbacks.delete(l))}},subscribeAll(l){return o.allCallbacks.add(l),()=>{o.allCallbacks.delete(l)}},stream(l){return B(o,{messages:t},l?.signal)},getTabId(){return n},close(){s.close(),o.channel=null,o.messageCallbacks.clear(),o.allCallbacks.clear(),o.messageResolvers.clear()}}}function b(e,r,a){let n=r.eventCallbacks.get(e.type);n&&n.forEach(s=>{try{s(e)}catch(o){console.error("Error in LeaderElector callback:",o)}}),r.allCallbacks.forEach(s=>{try{s(e)}catch(o){console.error("Error in LeaderElector all callback:",o)}}),a.push(e),r.eventResolvers.forEach(s=>s()),r.eventResolvers.clear()}function j(e,r){if(e.stopped)return!1;let a=m(e.key);if(!v(a)){let n={tabId:e.tabId,timestamp:Date.now(),leaseMs:e.leaseMs};if(g(e.key,n),m(e.key)?.tabId===e.tabId)return e.isLeader||(e.isLeader=!0,b(p("acquire",{tabId:e.tabId}),e,r)),!0}return a?.tabId===e.tabId&&v(a)?(e.isLeader||(e.isLeader=!0,b(p("acquire",{tabId:e.tabId}),e,r)),!0):(e.isLeader&&(e.isLeader=!1,b(p("lose",{tabId:e.tabId,newLeader:a?.tabId}),e,r)),!1)}function O(e,r){if(e.stopped||!e.isLeader)return;let a=m(e.key);if(a?.tabId===e.tabId){let n={...a,timestamp:Date.now()};g(e.key,n)}else e.isLeader&&(e.isLeader=!1,b(p("lose",{tabId:e.tabId}),e,r))}function P(e,r){if(e.stopped)return;let a=m(e.key),n=e.isLeader,s=a?.tabId===e.tabId&&v(a);!n&&s?(e.isLeader=!0,b(p("acquire",{tabId:e.tabId}),e,r)):n&&!s?(e.isLeader=!1,b(p("lose",{tabId:e.tabId,newLeader:a?.tabId}),e,r)):n&&s&&a?.tabId!==e.tabId&&b(p("change",{tabId:e.tabId,newLeader:a.tabId}),e,r)}function y(e){let{key:r,tabId:a,leaseMs:n=5e3,heartbeatMs:s=2e3,jitterMs:o=500}=e;if(typeof localStorage>"u")throw new Error("localStorage is not supported in this environment");let t={key:r,tabId:a,leaseMs:n,heartbeatMs:s,jitterMs:o,isLeader:!1,heartbeatTimer:null,checkTimer:null,eventCallbacks:new Map,allCallbacks:new Set,eventResolvers:new Set,activeIterators:0,stopped:!1},c=[];function l(i){i.key!==t.key||i.storageArea!==localStorage||P(t,c)}return typeof window<"u"&&window.addEventListener("storage",l),{start(){if(t.stopped&&(t.stopped=!1),j(t,c),t.isLeader){let u=L(t.heartbeatMs,t.jitterMs);t.heartbeatTimer=setInterval(()=>{O(t,c)},u)}let i=L(t.heartbeatMs/2,t.jitterMs);t.checkTimer=setInterval(()=>{P(t,c)},i)},stop(){t.stopped=!0,t.heartbeatTimer&&(clearInterval(t.heartbeatTimer),t.heartbeatTimer=null),t.checkTimer&&(clearInterval(t.checkTimer),t.checkTimer=null),t.isLeader&&(m(t.key)?.tabId===t.tabId&&k(t.key),t.isLeader=!1,b(p("lose",{tabId:t.tabId,reason:"stopped"}),t,c)),typeof window<"u"&&window.removeEventListener("storage",l)},isLeader(){return t.isLeader},on(i,u){return t.eventCallbacks.has(i)||t.eventCallbacks.set(i,new Set),t.eventCallbacks.get(i).add(u),()=>{let f=t.eventCallbacks.get(i);f&&(f.delete(u),f.size===0&&t.eventCallbacks.delete(i))}},onAll(i){return t.allCallbacks.add(i),()=>{t.allCallbacks.delete(i)}},stream(i){return S(t,{events:c},i?.signal)},getTabId(){return t.tabId}}}function $(e,r,a){if(e.tabId===a)return;let n=r.handlers.get(e.method);if(!n){let s={type:"rpc-response",requestId:e.requestId,error:`No handler found for method: ${e.method}`,tabId:a,ts:Date.now()};try{r.bus.publish("rpc-response",s)}catch{}return}Promise.resolve(n(e.params)).then(s=>{let o={type:"rpc-response",requestId:e.requestId,result:s,tabId:a,ts:Date.now()};try{r.bus.publish("rpc-response",o)}catch{}}).catch(s=>{let o={type:"rpc-response",requestId:e.requestId,error:s instanceof Error?s.message:String(s),tabId:a,ts:Date.now()};try{r.bus.publish("rpc-response",o)}catch{}})}function G(e,r,a){let n=r.pendingRequests.get(e.requestId);n&&(clearTimeout(n.timeout),r.pendingRequests.delete(e.requestId),e.error?n.reject(new Error(e.error)):n.resolve(e.result))}function h(e){let{bus:r,timeout:a=5e3}=e,n=r.getTabId(),s={bus:r,timeout:a,pendingRequests:new Map,handlers:new Map},o=r.subscribe("rpc-request",l=>{let d=l.payload;d&&d.type==="rpc-request"&&$(d,s,n)}),t=r.subscribe("rpc-response",l=>{let d=l.payload;d&&d.type==="rpc-response"&&G(d,s,n)});return{call(l,d,i){let u=C(),f=i?.timeout??s.timeout;return new Promise((x,E)=>{let q=setTimeout(()=>{s.pendingRequests.delete(u),E(new Error(`RPC call timeout: ${l} (${f}ms)`))},f);s.pendingRequests.set(u,{resolve:x,reject:E,timeout:q});let A={type:"rpc-request",method:l,params:d,requestId:u,tabId:n,ts:Date.now()};r.publish("rpc-request",A)})},handle(l,d){return s.handlers.set(l,d),()=>{s.handlers.delete(l)}},close(){s.pendingRequests.forEach(l=>{clearTimeout(l.timeout),l.reject(new Error("RPC closed"))}),s.pendingRequests.clear(),s.handlers.clear(),o(),t()}}}var ee={createBus:T,createLeaderElector:y,createRPC:h};export{T as createBus,y as createLeaderElector,h as createRPC,ee as default};
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "purrtabby",
3
+ "version": "0.1.0",
4
+ "description": "Lightweight browser tab communication and leader election library using BroadcastChannel and localStorage",
5
+ "main": "dist/index.cjs",
6
+ "module": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "unpkg": "dist/index.global.js",
9
+ "type": "module",
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsup src/index.ts --format cjs,esm,iife --dts --minify --globalName purrtabby",
17
+ "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
18
+ "test": "vitest run",
19
+ "test:watch": "vitest",
20
+ "test:coverage": "vitest run --coverage",
21
+ "prepublishOnly": "npm run build && npm test",
22
+ "version": "npm run build"
23
+ },
24
+ "keywords": [
25
+ "broadcastchannel",
26
+ "tab",
27
+ "communication",
28
+ "leader-election",
29
+ "localstorage",
30
+ "inter-tab",
31
+ "cross-tab",
32
+ "pub-sub",
33
+ "rpc",
34
+ "typescript",
35
+ "async-iterable",
36
+ "generator"
37
+ ],
38
+ "author": "minseon",
39
+ "license": "MIT",
40
+ "devDependencies": {
41
+ "tsup": "^8.0.0",
42
+ "typescript": "^5.0.0",
43
+ "vitest": "^2.0.0",
44
+ "@vitest/coverage-v8": "^2.0.0",
45
+ "@types/node": "^20.0.0",
46
+ "jsdom": "^24.0.0"
47
+ },
48
+ "engines": {
49
+ "node": ">=18.0.0"
50
+ },
51
+ "volta": {
52
+ "node": "24.13.0"
53
+ },
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "git+https://github.com/let-sunny/purrtabby.git"
57
+ },
58
+ "bugs": {
59
+ "url": "https://github.com/let-sunny/purrtabby/issues"
60
+ },
61
+ "homepage": "https://github.com/let-sunny/purrtabby#readme"
62
+ }