tab-bridge 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.
@@ -0,0 +1,1332 @@
1
+ 'use strict';
2
+
3
+ // src/types.ts
4
+ var PROTOCOL_VERSION = 1;
5
+
6
+ // src/utils/id.ts
7
+ function generateTabId() {
8
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
9
+ return crypto.randomUUID();
10
+ }
11
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
12
+ const r = Math.random() * 16 | 0;
13
+ const v = c === "x" ? r : r & 3 | 8;
14
+ return v.toString(16);
15
+ });
16
+ }
17
+
18
+ // src/utils/env.ts
19
+ var isBrowser = typeof window !== "undefined" && typeof window.document !== "undefined";
20
+ var hasDocument = typeof document !== "undefined";
21
+ var hasLocalStorage = (() => {
22
+ try {
23
+ return typeof localStorage !== "undefined" && localStorage !== null;
24
+ } catch {
25
+ return false;
26
+ }
27
+ })();
28
+ var hasBroadcastChannel = typeof BroadcastChannel !== "undefined";
29
+ var hasCrypto = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function";
30
+
31
+ // src/channels/broadcast.ts
32
+ var BroadcastChannelTransport = class {
33
+ constructor(channelName) {
34
+ this.closed = false;
35
+ this.bc = new BroadcastChannel(channelName);
36
+ }
37
+ postMessage(message) {
38
+ if (this.closed) return;
39
+ this.bc.postMessage(message);
40
+ }
41
+ onMessage(callback) {
42
+ const handler = (event) => {
43
+ callback(event.data);
44
+ };
45
+ this.bc.addEventListener("message", handler);
46
+ return () => this.bc.removeEventListener("message", handler);
47
+ }
48
+ close() {
49
+ if (this.closed) return;
50
+ this.closed = true;
51
+ this.bc.close();
52
+ }
53
+ };
54
+
55
+ // src/channels/storage.ts
56
+ var KEY_PREFIX = "__tab_sync__";
57
+ var StorageChannel = class {
58
+ constructor(channelName) {
59
+ this.listeners = /* @__PURE__ */ new Set();
60
+ this.closed = false;
61
+ this.seq = 0;
62
+ this.key = `${KEY_PREFIX}${channelName}`;
63
+ }
64
+ postMessage(message) {
65
+ if (this.closed || !hasLocalStorage) return;
66
+ try {
67
+ const wrapped = JSON.stringify({ m: message, s: this.seq++ });
68
+ localStorage.setItem(this.key, wrapped);
69
+ } catch {
70
+ }
71
+ }
72
+ onMessage(callback) {
73
+ if (!isBrowser) return () => {
74
+ };
75
+ const handler = (event) => {
76
+ if (event.key !== this.key || !event.newValue) return;
77
+ try {
78
+ const { m } = JSON.parse(event.newValue);
79
+ callback(m);
80
+ } catch {
81
+ }
82
+ };
83
+ this.listeners.add(handler);
84
+ window.addEventListener("storage", handler);
85
+ return () => {
86
+ this.listeners.delete(handler);
87
+ window.removeEventListener("storage", handler);
88
+ };
89
+ }
90
+ close() {
91
+ if (this.closed) return;
92
+ this.closed = true;
93
+ if (isBrowser) {
94
+ for (const handler of this.listeners) {
95
+ window.removeEventListener("storage", handler);
96
+ }
97
+ }
98
+ this.listeners.clear();
99
+ if (hasLocalStorage) {
100
+ try {
101
+ localStorage.removeItem(this.key);
102
+ } catch {
103
+ }
104
+ }
105
+ }
106
+ };
107
+
108
+ // src/channels/channel.ts
109
+ function createChannel(channelName, transport) {
110
+ if (transport === "local-storage") {
111
+ return new StorageChannel(channelName);
112
+ }
113
+ if (transport === "broadcast-channel") {
114
+ return new BroadcastChannelTransport(channelName);
115
+ }
116
+ if (typeof BroadcastChannel !== "undefined") {
117
+ return new BroadcastChannelTransport(channelName);
118
+ }
119
+ return new StorageChannel(channelName);
120
+ }
121
+
122
+ // src/utils/timestamp.ts
123
+ var lastTimestamp = 0;
124
+ function monotonic() {
125
+ const now = Date.now();
126
+ lastTimestamp = now > lastTimestamp ? now : lastTimestamp + 1;
127
+ return lastTimestamp;
128
+ }
129
+
130
+ // src/utils/batch.ts
131
+ function createBatcher(onFlush, delay = 16) {
132
+ const pending = /* @__PURE__ */ new Map();
133
+ let timer = null;
134
+ function flush() {
135
+ if (timer) {
136
+ clearTimeout(timer);
137
+ timer = null;
138
+ }
139
+ if (pending.size > 0) {
140
+ const snapshot = new Map(pending);
141
+ pending.clear();
142
+ onFlush(snapshot);
143
+ }
144
+ }
145
+ return {
146
+ add(key, value) {
147
+ pending.set(key, value);
148
+ if (!timer) {
149
+ timer = setTimeout(flush, delay);
150
+ }
151
+ },
152
+ flush,
153
+ destroy() {
154
+ if (timer) {
155
+ clearTimeout(timer);
156
+ timer = null;
157
+ }
158
+ pending.clear();
159
+ }
160
+ };
161
+ }
162
+
163
+ // src/core/state-manager.ts
164
+ var StateManager = class {
165
+ constructor(options) {
166
+ this.state = /* @__PURE__ */ new Map();
167
+ this.keyListeners = /* @__PURE__ */ new Map();
168
+ this.changeListeners = /* @__PURE__ */ new Set();
169
+ this.snapshotCache = null;
170
+ this.send = options.send;
171
+ this.tabId = options.tabId;
172
+ this.mergeFn = options.merge;
173
+ this.interceptRemote = options.interceptRemote;
174
+ this.afterRemoteChange = options.afterRemoteChange;
175
+ if (options.initial) {
176
+ for (const [key, value] of Object.entries(options.initial)) {
177
+ this.state.set(key, { value, timestamp: 0 });
178
+ }
179
+ }
180
+ this.batcher = createBatcher((entries) => {
181
+ const payload = { entries: {} };
182
+ for (const [key, entry] of entries) {
183
+ payload.entries[key] = { value: entry.value, timestamp: entry.timestamp };
184
+ }
185
+ this.send({
186
+ type: "STATE_UPDATE",
187
+ senderId: this.tabId,
188
+ timestamp: monotonic(),
189
+ payload
190
+ });
191
+ });
192
+ }
193
+ // ── Read ──
194
+ get(key) {
195
+ return this.state.get(key)?.value;
196
+ }
197
+ /**
198
+ * Returns a cached snapshot. The same reference is returned until state
199
+ * changes, making this safe for React's `useSyncExternalStore`.
200
+ */
201
+ getAll() {
202
+ if (!this.snapshotCache) {
203
+ const result = {};
204
+ for (const [key, entry] of this.state) {
205
+ result[key] = entry.value;
206
+ }
207
+ this.snapshotCache = result;
208
+ }
209
+ return this.snapshotCache;
210
+ }
211
+ // ── Write ──
212
+ set(key, value) {
213
+ const timestamp = monotonic();
214
+ const k = key;
215
+ this.state.set(k, { value, timestamp });
216
+ this.snapshotCache = null;
217
+ const meta = { sourceTabId: this.tabId, isLocal: true, timestamp };
218
+ this.notifyKey(k, value, meta);
219
+ this.notifyChange([key], meta);
220
+ this.batcher.add(k, { value, timestamp });
221
+ }
222
+ patch(partial) {
223
+ const entries = Object.entries(partial);
224
+ if (entries.length === 0) return;
225
+ const timestamp = monotonic();
226
+ const changedKeys = [];
227
+ for (const [key, value] of entries) {
228
+ this.state.set(key, { value, timestamp });
229
+ changedKeys.push(key);
230
+ this.batcher.add(key, { value, timestamp });
231
+ }
232
+ this.snapshotCache = null;
233
+ const meta = { sourceTabId: this.tabId, isLocal: true, timestamp };
234
+ for (const key of changedKeys) {
235
+ this.notifyKey(key, this.state.get(key).value, meta);
236
+ }
237
+ this.notifyChange(changedKeys, meta);
238
+ }
239
+ // ── Subscriptions ──
240
+ on(key, callback) {
241
+ const k = key;
242
+ let set = this.keyListeners.get(k);
243
+ if (!set) {
244
+ set = /* @__PURE__ */ new Set();
245
+ this.keyListeners.set(k, set);
246
+ }
247
+ set.add(callback);
248
+ return () => {
249
+ set.delete(callback);
250
+ };
251
+ }
252
+ onChange(callback) {
253
+ this.changeListeners.add(callback);
254
+ return () => {
255
+ this.changeListeners.delete(callback);
256
+ };
257
+ }
258
+ // ── Incoming messages ──
259
+ handleMessage(message) {
260
+ switch (message.type) {
261
+ case "STATE_UPDATE":
262
+ this.applyRemoteUpdate(message);
263
+ break;
264
+ case "STATE_SYNC_RESPONSE":
265
+ if (!message.targetId || message.targetId === this.tabId) {
266
+ this.applySyncResponse(message);
267
+ }
268
+ break;
269
+ }
270
+ }
271
+ // ── Sync protocol ──
272
+ requestSync() {
273
+ this.send({
274
+ type: "STATE_SYNC_REQUEST",
275
+ senderId: this.tabId,
276
+ timestamp: monotonic(),
277
+ payload: null
278
+ });
279
+ }
280
+ respondToSync(targetId) {
281
+ const state = {};
282
+ for (const [key, entry] of this.state) {
283
+ state[key] = { value: entry.value, timestamp: entry.timestamp };
284
+ }
285
+ this.send({
286
+ type: "STATE_SYNC_RESPONSE",
287
+ senderId: this.tabId,
288
+ targetId,
289
+ timestamp: monotonic(),
290
+ payload: { state }
291
+ });
292
+ }
293
+ flush() {
294
+ this.batcher.flush();
295
+ }
296
+ destroy() {
297
+ this.batcher.destroy();
298
+ this.keyListeners.clear();
299
+ this.changeListeners.clear();
300
+ }
301
+ // ── Private ──
302
+ applyRemoteUpdate(message) {
303
+ const { entries } = message.payload;
304
+ const changedKeys = [];
305
+ const meta = {
306
+ sourceTabId: message.senderId,
307
+ isLocal: false,
308
+ timestamp: message.timestamp
309
+ };
310
+ for (const [key, remote] of Object.entries(entries)) {
311
+ const local = this.state.get(key);
312
+ let finalValue;
313
+ if (this.mergeFn) {
314
+ finalValue = this.mergeFn(local?.value, remote.value, key);
315
+ } else if (this.shouldAcceptRemote(local, remote, message.senderId)) {
316
+ finalValue = remote.value;
317
+ } else {
318
+ continue;
319
+ }
320
+ if (this.interceptRemote) {
321
+ const result = this.interceptRemote(
322
+ key,
323
+ finalValue,
324
+ local?.value,
325
+ meta
326
+ );
327
+ if (result === false) continue;
328
+ if (result && "value" in result) finalValue = result.value;
329
+ }
330
+ this.state.set(key, { value: finalValue, timestamp: remote.timestamp });
331
+ changedKeys.push(key);
332
+ }
333
+ if (changedKeys.length > 0) {
334
+ this.snapshotCache = null;
335
+ for (const key of changedKeys) {
336
+ const val = this.state.get(key).value;
337
+ this.notifyKey(key, val, meta);
338
+ this.afterRemoteChange?.(key, val, meta);
339
+ }
340
+ this.notifyChange(changedKeys, meta);
341
+ }
342
+ }
343
+ /** LWW: accept remote if newer timestamp, or same timestamp with higher senderId. */
344
+ shouldAcceptRemote(local, remote, remoteSenderId) {
345
+ if (!local) return true;
346
+ if (remote.timestamp > local.timestamp) return true;
347
+ if (remote.timestamp === local.timestamp) return remoteSenderId > this.tabId;
348
+ return false;
349
+ }
350
+ applySyncResponse(message) {
351
+ const { state } = message.payload;
352
+ const changedKeys = [];
353
+ const meta = {
354
+ sourceTabId: message.senderId,
355
+ isLocal: false,
356
+ timestamp: message.timestamp
357
+ };
358
+ for (const [key, remote] of Object.entries(state)) {
359
+ const local = this.state.get(key);
360
+ if (!local || remote.timestamp > local.timestamp) {
361
+ this.state.set(key, { value: remote.value, timestamp: remote.timestamp });
362
+ changedKeys.push(key);
363
+ }
364
+ }
365
+ if (changedKeys.length > 0) {
366
+ this.snapshotCache = null;
367
+ for (const key of changedKeys) {
368
+ const val = this.state.get(key).value;
369
+ this.notifyKey(key, val, meta);
370
+ this.afterRemoteChange?.(key, val, meta);
371
+ }
372
+ this.notifyChange(changedKeys, meta);
373
+ }
374
+ }
375
+ notifyKey(key, value, meta) {
376
+ const listeners = this.keyListeners.get(key);
377
+ if (!listeners) return;
378
+ for (const cb of listeners) {
379
+ cb(value, meta);
380
+ }
381
+ }
382
+ notifyChange(changedKeys, meta) {
383
+ if (this.changeListeners.size === 0) return;
384
+ const snapshot = this.getAll();
385
+ for (const cb of this.changeListeners) {
386
+ cb(snapshot, changedKeys, meta);
387
+ }
388
+ }
389
+ };
390
+
391
+ // src/core/tab-registry.ts
392
+ var TabRegistry = class {
393
+ constructor(options) {
394
+ this.tabs = /* @__PURE__ */ new Map();
395
+ this.tabChangeListeners = /* @__PURE__ */ new Set();
396
+ this.heartbeatTimer = null;
397
+ this.pruneTimer = null;
398
+ this.visibilityHandler = null;
399
+ this.unloadHandler = null;
400
+ this.send = options.send;
401
+ this.tabId = options.tabId;
402
+ this.tabCreatedAt = options.tabCreatedAt;
403
+ this.heartbeatInterval = options.heartbeatInterval ?? 2e3;
404
+ this.tabTimeout = options.tabTimeout ?? 6e3;
405
+ this.registerSelf();
406
+ this.startHeartbeat();
407
+ this.startPruning();
408
+ this.listenVisibility();
409
+ this.listenUnload();
410
+ }
411
+ // ── Public API ──
412
+ getTabs() {
413
+ return Array.from(this.tabs.values());
414
+ }
415
+ getTabCount() {
416
+ return this.tabs.size;
417
+ }
418
+ getTab(id) {
419
+ return this.tabs.get(id);
420
+ }
421
+ onTabChange(callback) {
422
+ this.tabChangeListeners.add(callback);
423
+ return () => {
424
+ this.tabChangeListeners.delete(callback);
425
+ };
426
+ }
427
+ announce() {
428
+ this.send({
429
+ type: "TAB_ANNOUNCE",
430
+ senderId: this.tabId,
431
+ timestamp: monotonic(),
432
+ payload: this.buildAnnouncePayload()
433
+ });
434
+ }
435
+ handleMessage(message) {
436
+ switch (message.type) {
437
+ case "TAB_ANNOUNCE":
438
+ this.handleAnnounce(message.senderId, message.payload);
439
+ break;
440
+ case "TAB_GOODBYE":
441
+ this.handleGoodbye(message.senderId);
442
+ break;
443
+ case "LEADER_HEARTBEAT":
444
+ case "STATE_UPDATE":
445
+ case "LEADER_CLAIM":
446
+ case "LEADER_ACK":
447
+ this.touchTab(message.senderId);
448
+ break;
449
+ }
450
+ }
451
+ setLeader(tabId) {
452
+ let changed = false;
453
+ for (const [id, info] of this.tabs) {
454
+ const wasLeader = info.isLeader;
455
+ const isNowLeader = id === tabId;
456
+ if (wasLeader !== isNowLeader) {
457
+ this.tabs.set(id, { ...info, isLeader: isNowLeader });
458
+ changed = true;
459
+ }
460
+ }
461
+ if (changed) this.notifyChange();
462
+ }
463
+ destroy() {
464
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
465
+ if (this.pruneTimer) clearInterval(this.pruneTimer);
466
+ if (this.visibilityHandler && hasDocument) {
467
+ document.removeEventListener("visibilitychange", this.visibilityHandler);
468
+ }
469
+ if (this.unloadHandler && isBrowser) {
470
+ window.removeEventListener("beforeunload", this.unloadHandler);
471
+ }
472
+ this.sendGoodbye();
473
+ this.tabs.clear();
474
+ this.tabChangeListeners.clear();
475
+ }
476
+ // ── Private ──
477
+ registerSelf() {
478
+ this.tabs.set(this.tabId, {
479
+ id: this.tabId,
480
+ createdAt: this.tabCreatedAt,
481
+ lastSeen: Date.now(),
482
+ isLeader: false,
483
+ isActive: hasDocument ? document.visibilityState === "visible" : true,
484
+ url: isBrowser ? location.href : "",
485
+ title: hasDocument ? document.title : void 0
486
+ });
487
+ }
488
+ buildAnnouncePayload() {
489
+ return {
490
+ createdAt: this.tabCreatedAt,
491
+ isActive: hasDocument ? document.visibilityState === "visible" : true,
492
+ url: isBrowser ? location.href : "",
493
+ title: hasDocument ? document.title : void 0
494
+ };
495
+ }
496
+ startHeartbeat() {
497
+ this.heartbeatTimer = setInterval(() => {
498
+ this.touchSelf();
499
+ this.announce();
500
+ }, this.heartbeatInterval);
501
+ }
502
+ startPruning() {
503
+ this.pruneTimer = setInterval(() => {
504
+ this.pruneDeadTabs();
505
+ }, this.heartbeatInterval);
506
+ }
507
+ listenVisibility() {
508
+ if (!hasDocument) return;
509
+ this.visibilityHandler = () => {
510
+ const self = this.tabs.get(this.tabId);
511
+ if (self) {
512
+ const isActive = document.visibilityState === "visible";
513
+ this.tabs.set(this.tabId, { ...self, isActive, lastSeen: Date.now() });
514
+ this.announce();
515
+ }
516
+ };
517
+ document.addEventListener("visibilitychange", this.visibilityHandler);
518
+ }
519
+ listenUnload() {
520
+ if (!isBrowser) return;
521
+ this.unloadHandler = () => this.sendGoodbye();
522
+ window.addEventListener("beforeunload", this.unloadHandler);
523
+ }
524
+ sendGoodbye() {
525
+ this.send({
526
+ type: "TAB_GOODBYE",
527
+ senderId: this.tabId,
528
+ timestamp: monotonic(),
529
+ payload: null
530
+ });
531
+ }
532
+ handleAnnounce(senderId, payload) {
533
+ const existing = this.tabs.get(senderId);
534
+ this.tabs.set(senderId, {
535
+ id: senderId,
536
+ createdAt: payload.createdAt,
537
+ lastSeen: Date.now(),
538
+ isLeader: existing?.isLeader ?? false,
539
+ isActive: payload.isActive,
540
+ url: payload.url,
541
+ title: payload.title
542
+ });
543
+ this.notifyChange();
544
+ }
545
+ handleGoodbye(senderId) {
546
+ if (this.tabs.delete(senderId)) {
547
+ this.notifyChange();
548
+ }
549
+ }
550
+ touchTab(tabId) {
551
+ const tab = this.tabs.get(tabId);
552
+ if (tab) {
553
+ this.tabs.set(tabId, { ...tab, lastSeen: Date.now() });
554
+ }
555
+ }
556
+ touchSelf() {
557
+ const self = this.tabs.get(this.tabId);
558
+ if (self) {
559
+ this.tabs.set(this.tabId, { ...self, lastSeen: Date.now() });
560
+ }
561
+ }
562
+ pruneDeadTabs() {
563
+ const now = Date.now();
564
+ let changed = false;
565
+ for (const [id, info] of this.tabs) {
566
+ if (id === this.tabId) continue;
567
+ if (now - info.lastSeen > this.tabTimeout) {
568
+ this.tabs.delete(id);
569
+ changed = true;
570
+ }
571
+ }
572
+ if (changed) this.notifyChange();
573
+ }
574
+ notifyChange() {
575
+ if (this.tabChangeListeners.size === 0) return;
576
+ const tabs = this.getTabs();
577
+ for (const cb of this.tabChangeListeners) {
578
+ cb(tabs);
579
+ }
580
+ }
581
+ };
582
+
583
+ // src/core/leader-election.ts
584
+ var LeaderElection = class {
585
+ constructor(options) {
586
+ this.leaderId = null;
587
+ this.electionTimer = null;
588
+ this.heartbeatTimer = null;
589
+ this.leaderWatchTimer = null;
590
+ this.lastLeaderHeartbeat = 0;
591
+ this.electing = false;
592
+ this.leaderCallbacks = /* @__PURE__ */ new Set();
593
+ this.leaderCleanups = /* @__PURE__ */ new Map();
594
+ this.send = options.send;
595
+ this.tabId = options.tabId;
596
+ this.tabCreatedAt = options.tabCreatedAt;
597
+ this.electionTimeout = options.electionTimeout ?? 300;
598
+ this.heartbeatInterval = options.heartbeatInterval ?? 2e3;
599
+ const missedLimit = options.missedHeartbeatsLimit ?? 3;
600
+ this.leaderTimeout = this.heartbeatInterval * missedLimit;
601
+ }
602
+ // ── Public API ──
603
+ isLeader() {
604
+ return this.leaderId === this.tabId;
605
+ }
606
+ getLeaderId() {
607
+ return this.leaderId;
608
+ }
609
+ onLeader(callback) {
610
+ this.leaderCallbacks.add(callback);
611
+ if (this.isLeader()) {
612
+ const cleanup = callback();
613
+ if (typeof cleanup === "function") {
614
+ this.leaderCleanups.set(callback, cleanup);
615
+ }
616
+ }
617
+ return () => {
618
+ this.leaderCallbacks.delete(callback);
619
+ const cleanup = this.leaderCleanups.get(callback);
620
+ if (cleanup) {
621
+ cleanup();
622
+ this.leaderCleanups.delete(callback);
623
+ }
624
+ };
625
+ }
626
+ start() {
627
+ this.startElection();
628
+ this.startLeaderWatch();
629
+ }
630
+ handleMessage(message) {
631
+ switch (message.type) {
632
+ case "LEADER_CLAIM":
633
+ this.handleClaim(message.payload, message.senderId);
634
+ break;
635
+ case "LEADER_ACK":
636
+ this.handleAck(message.senderId);
637
+ break;
638
+ case "LEADER_HEARTBEAT":
639
+ this.handleHeartbeat(message.senderId);
640
+ break;
641
+ case "LEADER_RESIGN":
642
+ this.handleResign(message.senderId);
643
+ break;
644
+ }
645
+ }
646
+ destroy() {
647
+ if (this.isLeader()) {
648
+ this.send({
649
+ type: "LEADER_RESIGN",
650
+ senderId: this.tabId,
651
+ timestamp: monotonic(),
652
+ payload: null
653
+ });
654
+ }
655
+ this.clearElectionTimer();
656
+ this.clearHeartbeat();
657
+ this.clearLeaderWatch();
658
+ this.runCleanups();
659
+ this.leaderCallbacks.clear();
660
+ this.leaderId = null;
661
+ }
662
+ // ── Election ──
663
+ startElection() {
664
+ if (this.electing) return;
665
+ this.electing = true;
666
+ this.send({
667
+ type: "LEADER_CLAIM",
668
+ senderId: this.tabId,
669
+ timestamp: monotonic(),
670
+ payload: { createdAt: this.tabCreatedAt }
671
+ });
672
+ this.electionTimer = setTimeout(() => {
673
+ this.electionTimer = null;
674
+ this.electing = false;
675
+ this.becomeLeader();
676
+ }, this.electionTimeout);
677
+ }
678
+ handleClaim(payload, senderId) {
679
+ if (this.hasPriority(payload.createdAt, senderId)) {
680
+ if (!this.electing) this.startElection();
681
+ } else {
682
+ this.clearElectionTimer();
683
+ this.electing = false;
684
+ }
685
+ }
686
+ handleAck(senderId) {
687
+ this.clearElectionTimer();
688
+ this.electing = false;
689
+ this.setLeader(senderId);
690
+ }
691
+ handleHeartbeat(senderId) {
692
+ if (senderId === this.leaderId || !this.leaderId) {
693
+ this.setLeader(senderId);
694
+ }
695
+ this.lastLeaderHeartbeat = Date.now();
696
+ }
697
+ handleResign(senderId) {
698
+ if (senderId === this.leaderId) {
699
+ this.leaderId = null;
700
+ this.runCleanups();
701
+ this.startElection();
702
+ }
703
+ }
704
+ becomeLeader() {
705
+ this.setLeader(this.tabId);
706
+ this.send({
707
+ type: "LEADER_ACK",
708
+ senderId: this.tabId,
709
+ timestamp: monotonic(),
710
+ payload: null
711
+ });
712
+ this.startHeartbeat();
713
+ }
714
+ setLeader(id) {
715
+ const wasLeader = this.isLeader();
716
+ this.leaderId = id;
717
+ this.lastLeaderHeartbeat = Date.now();
718
+ if (this.isLeader() && !wasLeader) {
719
+ this.startHeartbeat();
720
+ for (const cb of this.leaderCallbacks) {
721
+ const cleanup = cb();
722
+ if (typeof cleanup === "function") {
723
+ this.leaderCleanups.set(cb, cleanup);
724
+ }
725
+ }
726
+ } else if (!this.isLeader() && wasLeader) {
727
+ this.clearHeartbeat();
728
+ this.runCleanups();
729
+ }
730
+ }
731
+ /**
732
+ * Priority: oldest tab (smaller `createdAt`) wins.
733
+ * Tiebreak: lower `tabId` wins (deterministic).
734
+ */
735
+ hasPriority(remoteCreatedAt, remoteTabId) {
736
+ if (this.tabCreatedAt < remoteCreatedAt) return true;
737
+ if (this.tabCreatedAt > remoteCreatedAt) return false;
738
+ return this.tabId < remoteTabId;
739
+ }
740
+ // ── Heartbeat ──
741
+ startHeartbeat() {
742
+ this.clearHeartbeat();
743
+ this.heartbeatTimer = setInterval(() => {
744
+ if (!this.isLeader()) return;
745
+ this.send({
746
+ type: "LEADER_HEARTBEAT",
747
+ senderId: this.tabId,
748
+ timestamp: monotonic(),
749
+ payload: null
750
+ });
751
+ }, this.heartbeatInterval);
752
+ }
753
+ clearHeartbeat() {
754
+ if (this.heartbeatTimer) {
755
+ clearInterval(this.heartbeatTimer);
756
+ this.heartbeatTimer = null;
757
+ }
758
+ }
759
+ // ── Leader Watch ──
760
+ startLeaderWatch() {
761
+ this.lastLeaderHeartbeat = Date.now();
762
+ this.leaderWatchTimer = setInterval(() => {
763
+ if (this.isLeader()) return;
764
+ if (this.leaderId && Date.now() - this.lastLeaderHeartbeat > this.leaderTimeout) {
765
+ this.leaderId = null;
766
+ this.runCleanups();
767
+ this.startElection();
768
+ }
769
+ }, this.heartbeatInterval);
770
+ }
771
+ clearLeaderWatch() {
772
+ if (this.leaderWatchTimer) {
773
+ clearInterval(this.leaderWatchTimer);
774
+ this.leaderWatchTimer = null;
775
+ }
776
+ }
777
+ clearElectionTimer() {
778
+ if (this.electionTimer) {
779
+ clearTimeout(this.electionTimer);
780
+ this.electionTimer = null;
781
+ }
782
+ }
783
+ runCleanups() {
784
+ for (const cleanup of this.leaderCleanups.values()) {
785
+ cleanup();
786
+ }
787
+ this.leaderCleanups.clear();
788
+ }
789
+ };
790
+
791
+ // src/utils/errors.ts
792
+ var ErrorCode = {
793
+ CHANNEL_CLOSED: "CHANNEL_CLOSED",
794
+ CHANNEL_SEND_FAILED: "CHANNEL_SEND_FAILED",
795
+ RPC_TIMEOUT: "RPC_TIMEOUT",
796
+ RPC_NO_HANDLER: "RPC_NO_HANDLER",
797
+ RPC_NO_LEADER: "RPC_NO_LEADER",
798
+ RPC_HANDLER_ERROR: "RPC_HANDLER_ERROR",
799
+ RPC_DESTROYED: "RPC_DESTROYED",
800
+ STORAGE_QUOTA_EXCEEDED: "STORAGE_QUOTA_EXCEEDED",
801
+ MIDDLEWARE_REJECTED: "MIDDLEWARE_REJECTED",
802
+ ALREADY_DESTROYED: "ALREADY_DESTROYED"
803
+ };
804
+ var TabSyncError = class _TabSyncError extends Error {
805
+ constructor(message, code, cause) {
806
+ super(message);
807
+ this.code = code;
808
+ this.name = "TabSyncError";
809
+ this.cause = cause;
810
+ }
811
+ static timeout(method, ms) {
812
+ return new _TabSyncError(
813
+ `RPC "${method}" timed out after ${ms}ms`,
814
+ ErrorCode.RPC_TIMEOUT
815
+ );
816
+ }
817
+ static noLeader() {
818
+ return new _TabSyncError("No leader available", ErrorCode.RPC_NO_LEADER);
819
+ }
820
+ static noHandler(method) {
821
+ return new _TabSyncError(
822
+ `No handler registered for "${method}"`,
823
+ ErrorCode.RPC_NO_HANDLER
824
+ );
825
+ }
826
+ static destroyed() {
827
+ return new _TabSyncError("Instance has been destroyed", ErrorCode.ALREADY_DESTROYED);
828
+ }
829
+ };
830
+
831
+ // src/core/rpc.ts
832
+ var DEFAULT_TIMEOUT = 5e3;
833
+ var RPCHandler = class {
834
+ constructor(options) {
835
+ this.handlers = /* @__PURE__ */ new Map();
836
+ this.pending = /* @__PURE__ */ new Map();
837
+ this.send = options.send;
838
+ this.tabId = options.tabId;
839
+ this.resolveLeaderId = options.resolveLeaderId ?? (() => null);
840
+ this.onError = options.onError ?? (() => {
841
+ });
842
+ }
843
+ call(targetTabId, method, args, timeout = DEFAULT_TIMEOUT) {
844
+ const resolvedTarget = targetTabId === "leader" ? this.resolveLeaderId() : targetTabId;
845
+ if (!resolvedTarget) {
846
+ return Promise.reject(TabSyncError.noLeader());
847
+ }
848
+ const callId = generateTabId();
849
+ return new Promise((resolve, reject) => {
850
+ const timer = setTimeout(() => {
851
+ this.pending.delete(callId);
852
+ reject(TabSyncError.timeout(method, timeout));
853
+ }, timeout);
854
+ this.pending.set(callId, {
855
+ resolve,
856
+ reject,
857
+ timer
858
+ });
859
+ this.send({
860
+ type: "RPC_REQUEST",
861
+ senderId: this.tabId,
862
+ targetId: resolvedTarget,
863
+ timestamp: monotonic(),
864
+ payload: { callId, method, args }
865
+ });
866
+ });
867
+ }
868
+ handle(method, handler) {
869
+ this.handlers.set(
870
+ method,
871
+ handler
872
+ );
873
+ return () => {
874
+ this.handlers.delete(method);
875
+ };
876
+ }
877
+ handleMessage(message) {
878
+ switch (message.type) {
879
+ case "RPC_REQUEST":
880
+ if (!message.targetId || message.targetId === this.tabId) {
881
+ this.handleRequest(message);
882
+ }
883
+ break;
884
+ case "RPC_RESPONSE":
885
+ if (!message.targetId || message.targetId === this.tabId) {
886
+ this.handleResponse(message);
887
+ }
888
+ break;
889
+ }
890
+ }
891
+ destroy() {
892
+ const error = TabSyncError.destroyed();
893
+ for (const [, call] of this.pending) {
894
+ clearTimeout(call.timer);
895
+ call.reject(error);
896
+ }
897
+ this.pending.clear();
898
+ this.handlers.clear();
899
+ }
900
+ // ── Private ──
901
+ async handleRequest(message) {
902
+ const { callId, method, args } = message.payload;
903
+ const handler = this.handlers.get(method);
904
+ if (!handler) {
905
+ const err = TabSyncError.noHandler(method);
906
+ this.onError(err);
907
+ this.sendResponse(message.senderId, callId, void 0, err.message);
908
+ return;
909
+ }
910
+ try {
911
+ const result = await handler(args, message.senderId);
912
+ this.sendResponse(message.senderId, callId, result);
913
+ } catch (err) {
914
+ const errorMsg = err instanceof Error ? err.message : String(err);
915
+ this.onError(
916
+ new TabSyncError(errorMsg, ErrorCode.RPC_HANDLER_ERROR, err)
917
+ );
918
+ this.sendResponse(message.senderId, callId, void 0, errorMsg);
919
+ }
920
+ }
921
+ handleResponse(message) {
922
+ const { callId, result, error } = message.payload;
923
+ const call = this.pending.get(callId);
924
+ if (!call) return;
925
+ clearTimeout(call.timer);
926
+ this.pending.delete(callId);
927
+ if (error) {
928
+ call.reject(
929
+ new TabSyncError(error, ErrorCode.RPC_HANDLER_ERROR)
930
+ );
931
+ } else {
932
+ call.resolve(result);
933
+ }
934
+ }
935
+ sendResponse(targetId, callId, result, error) {
936
+ this.send({
937
+ type: "RPC_RESPONSE",
938
+ senderId: this.tabId,
939
+ targetId,
940
+ timestamp: monotonic(),
941
+ payload: { callId, result, error }
942
+ });
943
+ }
944
+ };
945
+
946
+ // src/core/middleware.ts
947
+ function runMiddleware(middlewares, ctx) {
948
+ let currentValue = ctx.value;
949
+ for (const mw of middlewares) {
950
+ const fn = mw.onSet;
951
+ if (!fn) continue;
952
+ const result = fn({ ...ctx, value: currentValue });
953
+ if (result === false) {
954
+ return { value: currentValue, rejected: true };
955
+ }
956
+ if (result && "value" in result) {
957
+ currentValue = result.value;
958
+ }
959
+ }
960
+ return { value: currentValue, rejected: false };
961
+ }
962
+ function notifyMiddleware(middlewares, key, value, meta) {
963
+ for (const mw of middlewares) {
964
+ mw.afterChange?.(key, value, meta);
965
+ }
966
+ }
967
+ function destroyMiddleware(middlewares) {
968
+ for (const mw of middlewares) {
969
+ mw.onDestroy?.();
970
+ }
971
+ }
972
+
973
+ // src/core/tab-sync.ts
974
+ function resolvePersistOptions(opt) {
975
+ if (!opt) return null;
976
+ if (opt === true) return {};
977
+ return opt;
978
+ }
979
+ function loadPersistedState(opts) {
980
+ const storage = opts.storage ?? (hasLocalStorage ? localStorage : null);
981
+ if (!storage) return {};
982
+ const key = opts.key ?? "tab-sync:state";
983
+ const deserialize = opts.deserialize ?? JSON.parse;
984
+ try {
985
+ const raw = storage.getItem(key);
986
+ if (!raw) return {};
987
+ const parsed = deserialize(raw);
988
+ return filterPersistKeys(parsed, opts);
989
+ } catch {
990
+ return {};
991
+ }
992
+ }
993
+ function filterPersistKeys(state, opts) {
994
+ const include = opts.include ? new Set(opts.include) : null;
995
+ const exclude = opts.exclude ? new Set(opts.exclude) : null;
996
+ const result = {};
997
+ for (const [key, value] of Object.entries(state)) {
998
+ const k = key;
999
+ if (exclude?.has(k)) continue;
1000
+ if (include && !include.has(k)) continue;
1001
+ result[key] = value;
1002
+ }
1003
+ return result;
1004
+ }
1005
+ function createPersistSaver(opts, onError) {
1006
+ const storage = opts.storage ?? (hasLocalStorage ? localStorage : null);
1007
+ if (!storage) return { save() {
1008
+ }, flush() {
1009
+ }, destroy() {
1010
+ } };
1011
+ const key = opts.key ?? "tab-sync:state";
1012
+ const serialize = opts.serialize ?? JSON.stringify;
1013
+ const debounce = opts.debounce ?? 100;
1014
+ let timer = null;
1015
+ let latestState = null;
1016
+ function doSave() {
1017
+ if (!latestState) return;
1018
+ try {
1019
+ const filtered = filterPersistKeys({ ...latestState }, opts);
1020
+ storage.setItem(key, serialize(filtered));
1021
+ } catch (e) {
1022
+ onError(e instanceof Error ? e : new Error(String(e)));
1023
+ }
1024
+ latestState = null;
1025
+ }
1026
+ return {
1027
+ save(state) {
1028
+ latestState = state;
1029
+ if (!timer) {
1030
+ timer = setTimeout(() => {
1031
+ timer = null;
1032
+ doSave();
1033
+ }, debounce);
1034
+ }
1035
+ },
1036
+ flush() {
1037
+ if (timer) {
1038
+ clearTimeout(timer);
1039
+ timer = null;
1040
+ }
1041
+ doSave();
1042
+ },
1043
+ destroy() {
1044
+ if (timer) {
1045
+ clearTimeout(timer);
1046
+ timer = null;
1047
+ }
1048
+ doSave();
1049
+ }
1050
+ };
1051
+ }
1052
+ function createLogger(enabled, tabId) {
1053
+ if (!enabled) return { log: (() => {
1054
+ }) };
1055
+ const prefix = `%c[tab-sync:${tabId.slice(0, 8)}]`;
1056
+ const style = "color:#818cf8;font-weight:600";
1057
+ return {
1058
+ log: (label, ...args) => console.log(prefix, style, label, ...args)
1059
+ };
1060
+ }
1061
+ function createTabSync(options) {
1062
+ const opts = options ?? {};
1063
+ const tabId = generateTabId();
1064
+ const tabCreatedAt = Date.now();
1065
+ const channelName = opts.channel ?? "tab-sync";
1066
+ const debug = opts.debug ?? false;
1067
+ const onError = opts.onError ?? (() => {
1068
+ });
1069
+ const leaderEnabled = opts.leader !== false;
1070
+ const leaderOpts = typeof opts.leader === "object" ? opts.leader : {};
1071
+ const heartbeatInterval = opts.heartbeatInterval ?? leaderOpts.heartbeatInterval ?? 2e3;
1072
+ const leaderTimeout = opts.leaderTimeout ?? leaderOpts.leaderTimeout ?? 6e3;
1073
+ const missedHeartbeatsLimit = Math.max(
1074
+ 1,
1075
+ Math.round(leaderTimeout / heartbeatInterval)
1076
+ );
1077
+ const middlewares = [...opts.middlewares ?? []];
1078
+ const persistOpts = resolvePersistOptions(opts.persist);
1079
+ const persister = persistOpts ? createPersistSaver(persistOpts, onError) : null;
1080
+ let initialState = opts.initial ?? {};
1081
+ if (persistOpts) {
1082
+ const restored = loadPersistedState(persistOpts);
1083
+ if (Object.keys(restored).length > 0) {
1084
+ initialState = { ...initialState, ...restored };
1085
+ }
1086
+ }
1087
+ const channel = createChannel(channelName, opts.transport);
1088
+ const { log } = createLogger(debug, tabId);
1089
+ const send = (message) => {
1090
+ log("\u2192", message.type, message.payload);
1091
+ channel.postMessage(message);
1092
+ };
1093
+ const stateManager = new StateManager({
1094
+ send,
1095
+ tabId,
1096
+ initial: initialState,
1097
+ merge: opts.merge,
1098
+ afterRemoteChange(key, value, meta) {
1099
+ notifyMiddleware(middlewares, key, value, meta);
1100
+ if (persister) persister.save(stateManager.getAll());
1101
+ }
1102
+ });
1103
+ const registry = new TabRegistry({
1104
+ send,
1105
+ tabId,
1106
+ tabCreatedAt,
1107
+ heartbeatInterval,
1108
+ tabTimeout: leaderTimeout
1109
+ });
1110
+ let election = null;
1111
+ if (leaderEnabled) {
1112
+ election = new LeaderElection({
1113
+ send,
1114
+ tabId,
1115
+ tabCreatedAt,
1116
+ heartbeatInterval,
1117
+ missedHeartbeatsLimit
1118
+ });
1119
+ }
1120
+ const rpc = new RPCHandler({
1121
+ send,
1122
+ tabId,
1123
+ resolveLeaderId: () => election?.getLeaderId() ?? null,
1124
+ onError
1125
+ });
1126
+ const unsubChannel = channel.onMessage((message) => {
1127
+ log("\u2190", message.type, `from=${message.senderId}`);
1128
+ if (message.senderId === tabId) return;
1129
+ if (message.version && message.version > PROTOCOL_VERSION) {
1130
+ log("\u26A0\uFE0F", `Unknown protocol v${message.version}, ignoring`);
1131
+ return;
1132
+ }
1133
+ registry.handleMessage(message);
1134
+ switch (message.type) {
1135
+ case "STATE_UPDATE":
1136
+ case "STATE_SYNC_RESPONSE":
1137
+ stateManager.handleMessage(message);
1138
+ break;
1139
+ case "STATE_SYNC_REQUEST":
1140
+ if (election?.isLeader() ?? true) {
1141
+ stateManager.respondToSync(message.senderId);
1142
+ }
1143
+ break;
1144
+ case "LEADER_CLAIM":
1145
+ case "LEADER_ACK":
1146
+ case "LEADER_HEARTBEAT":
1147
+ case "LEADER_RESIGN":
1148
+ election?.handleMessage(message);
1149
+ if (election) {
1150
+ registry.setLeader(election.getLeaderId());
1151
+ }
1152
+ break;
1153
+ case "RPC_REQUEST":
1154
+ case "RPC_RESPONSE":
1155
+ rpc.handleMessage(message);
1156
+ break;
1157
+ }
1158
+ });
1159
+ registry.announce();
1160
+ stateManager.requestSync();
1161
+ election?.start();
1162
+ let ready = true;
1163
+ let destroyed = false;
1164
+ function middlewareSet(key, value) {
1165
+ if (middlewares.length === 0) {
1166
+ stateManager.set(key, value);
1167
+ if (persister) persister.save(stateManager.getAll());
1168
+ return;
1169
+ }
1170
+ const meta = { sourceTabId: tabId, isLocal: true, timestamp: Date.now() };
1171
+ const { value: finalValue, rejected } = runMiddleware(middlewares, {
1172
+ key,
1173
+ value,
1174
+ previousValue: stateManager.get(key),
1175
+ meta
1176
+ });
1177
+ if (rejected) {
1178
+ log("\u{1F6AB}", `Middleware rejected set("${String(key)}")`);
1179
+ return;
1180
+ }
1181
+ stateManager.set(key, finalValue);
1182
+ notifyMiddleware(middlewares, key, finalValue, meta);
1183
+ if (persister) persister.save(stateManager.getAll());
1184
+ }
1185
+ function middlewarePatch(partial) {
1186
+ if (middlewares.length === 0) {
1187
+ stateManager.patch(partial);
1188
+ if (persister) persister.save(stateManager.getAll());
1189
+ return;
1190
+ }
1191
+ const meta = { sourceTabId: tabId, isLocal: true, timestamp: Date.now() };
1192
+ const filtered = {};
1193
+ const appliedKeys = [];
1194
+ for (const [key, value] of Object.entries(partial)) {
1195
+ const k = key;
1196
+ const { value: finalValue, rejected } = runMiddleware(middlewares, {
1197
+ key: k,
1198
+ value,
1199
+ previousValue: stateManager.get(k),
1200
+ meta
1201
+ });
1202
+ if (!rejected) {
1203
+ filtered[key] = finalValue;
1204
+ appliedKeys.push(k);
1205
+ }
1206
+ }
1207
+ if (Object.keys(filtered).length > 0) {
1208
+ stateManager.patch(filtered);
1209
+ for (const k of appliedKeys) {
1210
+ notifyMiddleware(middlewares, k, stateManager.get(k), meta);
1211
+ }
1212
+ if (persister) persister.save(stateManager.getAll());
1213
+ }
1214
+ }
1215
+ const instance = {
1216
+ // State
1217
+ get: (key) => stateManager.get(key),
1218
+ getAll: () => stateManager.getAll(),
1219
+ set: middlewareSet,
1220
+ patch: middlewarePatch,
1221
+ // Subscriptions
1222
+ on: (key, callback) => stateManager.on(key, callback),
1223
+ once: (key, callback) => {
1224
+ const unsub = stateManager.on(key, ((value, meta) => {
1225
+ unsub();
1226
+ callback(value, meta);
1227
+ }));
1228
+ return unsub;
1229
+ },
1230
+ onChange: (callback) => stateManager.onChange(callback),
1231
+ select: (selector, callback, isEqual = Object.is) => {
1232
+ let prev = selector(stateManager.getAll());
1233
+ return stateManager.onChange((state, _keys, meta) => {
1234
+ const next = selector(state);
1235
+ if (!isEqual(prev, next)) {
1236
+ prev = next;
1237
+ callback(next, meta);
1238
+ }
1239
+ });
1240
+ },
1241
+ // Leader
1242
+ isLeader: () => election?.isLeader() ?? true,
1243
+ onLeader: (callback) => {
1244
+ if (!election) {
1245
+ const cleanup = callback();
1246
+ return () => {
1247
+ if (typeof cleanup === "function") cleanup();
1248
+ };
1249
+ }
1250
+ return election.onLeader(callback);
1251
+ },
1252
+ getLeader: () => {
1253
+ const leaderId = election?.getLeaderId();
1254
+ if (!leaderId) return null;
1255
+ return registry.getTab(leaderId) ?? null;
1256
+ },
1257
+ waitForLeader: () => {
1258
+ const leader = instance.getLeader();
1259
+ if (leader) return Promise.resolve(leader);
1260
+ return new Promise((resolve) => {
1261
+ const unsubs = [];
1262
+ const check = () => {
1263
+ const l = instance.getLeader();
1264
+ if (l) {
1265
+ for (const u of unsubs) u();
1266
+ resolve(l);
1267
+ }
1268
+ };
1269
+ unsubs.push(registry.onTabChange(check));
1270
+ if (election) {
1271
+ unsubs.push(election.onLeader(() => {
1272
+ check();
1273
+ return () => {
1274
+ };
1275
+ }));
1276
+ }
1277
+ });
1278
+ },
1279
+ // Tabs
1280
+ id: tabId,
1281
+ getTabs: () => registry.getTabs(),
1282
+ getTabCount: () => registry.getTabCount(),
1283
+ onTabChange: (callback) => registry.onTabChange(callback),
1284
+ // RPC
1285
+ call: ((target, method, args, timeout) => rpc.call(target, method, args, timeout)),
1286
+ handle: ((method, handler) => rpc.handle(method, handler)),
1287
+ // Lifecycle
1288
+ destroy: () => {
1289
+ if (destroyed) return;
1290
+ destroyed = true;
1291
+ ready = false;
1292
+ stateManager.flush();
1293
+ persister?.destroy();
1294
+ destroyMiddleware(middlewares);
1295
+ election?.destroy();
1296
+ registry.destroy();
1297
+ rpc.destroy();
1298
+ stateManager.destroy();
1299
+ unsubChannel();
1300
+ channel.close();
1301
+ log("\u{1F480}", "Instance destroyed");
1302
+ },
1303
+ get ready() {
1304
+ return ready;
1305
+ }
1306
+ };
1307
+ log("\u{1F680}", "Instance created", { channel: channelName, leader: leaderEnabled });
1308
+ return instance;
1309
+ }
1310
+
1311
+ exports.BroadcastChannelTransport = BroadcastChannelTransport;
1312
+ exports.ErrorCode = ErrorCode;
1313
+ exports.LeaderElection = LeaderElection;
1314
+ exports.PROTOCOL_VERSION = PROTOCOL_VERSION;
1315
+ exports.RPCHandler = RPCHandler;
1316
+ exports.StateManager = StateManager;
1317
+ exports.StorageChannel = StorageChannel;
1318
+ exports.TabRegistry = TabRegistry;
1319
+ exports.TabSyncError = TabSyncError;
1320
+ exports.createBatcher = createBatcher;
1321
+ exports.createChannel = createChannel;
1322
+ exports.createTabSync = createTabSync;
1323
+ exports.destroyMiddleware = destroyMiddleware;
1324
+ exports.generateTabId = generateTabId;
1325
+ exports.hasBroadcastChannel = hasBroadcastChannel;
1326
+ exports.hasCrypto = hasCrypto;
1327
+ exports.hasDocument = hasDocument;
1328
+ exports.hasLocalStorage = hasLocalStorage;
1329
+ exports.isBrowser = isBrowser;
1330
+ exports.monotonic = monotonic;
1331
+ exports.notifyMiddleware = notifyMiddleware;
1332
+ exports.runMiddleware = runMiddleware;