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 +21 -0
- package/README.md +436 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +135 -0
- package/dist/index.d.ts +135 -0
- package/dist/index.global.js +1 -0
- package/dist/index.js +1 -0
- package/package.json +62 -0
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});
|
package/dist/index.d.cts
ADDED
|
@@ -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 };
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|