tab-bridge 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +191 -1
- package/dist/index.d.cts +4 -2
- package/dist/index.d.ts +4 -2
- package/dist/{instance-5LIItazN.d.cts → instance-hvEUHx6i.d.cts} +1 -91
- package/dist/{instance-5LIItazN.d.ts → instance-hvEUHx6i.d.ts} +1 -91
- package/dist/options-DmHyGTL0.d.cts +93 -0
- package/dist/options-iN7Rnvwj.d.ts +93 -0
- package/dist/react/index.d.cts +2 -1
- package/dist/react/index.d.ts +2 -1
- package/dist/zustand/index.cjs +80 -0
- package/dist/zustand/index.d.cts +87 -0
- package/dist/zustand/index.d.ts +87 -0
- package/dist/zustand/index.js +78 -0
- package/package.json +18 -3
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
<br />
|
|
28
28
|
|
|
29
|
-
[**Getting Started**](#-getting-started) · [**API**](#-api-reference) · [**React**](#%EF%B8%8F-react) · [**Architecture**](#-architecture) · [**Examples**](#-examples) · [**Live Demo**](https://serbi2012.github.io/tab-bridge/)
|
|
29
|
+
[**Getting Started**](#-getting-started) · [**API**](#-api-reference) · [**React**](#%EF%B8%8F-react) · [**Zustand**](#-zustand) · [**Next.js**](#-nextjs) · [**Architecture**](#-architecture) · [**Examples**](#-examples) · [**Live Demo**](https://serbi2012.github.io/tab-bridge/)
|
|
30
30
|
|
|
31
31
|
</div>
|
|
32
32
|
|
|
@@ -76,6 +76,9 @@ Intercept, validate, and transform state changes before they're applied
|
|
|
76
76
|
#### 💾 State Persistence
|
|
77
77
|
Survive page reloads with key whitelisting and custom storage backends
|
|
78
78
|
|
|
79
|
+
#### 🐻 Zustand Middleware
|
|
80
|
+
One-line integration — `tabSync()` wraps any Zustand store for cross-tab sync
|
|
81
|
+
|
|
79
82
|
#### 📦 Zero Dependencies
|
|
80
83
|
Native browser APIs only, ~4KB gzipped, fully tree-shakable
|
|
81
84
|
|
|
@@ -513,6 +516,180 @@ Components using only `useTabSyncActions` **never re-render** due to state chang
|
|
|
513
516
|
|
|
514
517
|
<br />
|
|
515
518
|
|
|
519
|
+
## 🐻 Zustand
|
|
520
|
+
|
|
521
|
+
One-line integration for [Zustand](https://github.com/pmndrs/zustand) stores — all tabs stay in sync automatically.
|
|
522
|
+
|
|
523
|
+
```bash
|
|
524
|
+
npm install zustand
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
```ts
|
|
528
|
+
import { create } from 'zustand';
|
|
529
|
+
import { tabSync } from 'tab-bridge/zustand';
|
|
530
|
+
|
|
531
|
+
const useStore = create(
|
|
532
|
+
tabSync(
|
|
533
|
+
(set) => ({
|
|
534
|
+
count: 0,
|
|
535
|
+
theme: 'light',
|
|
536
|
+
inc: () => set((s) => ({ count: s.count + 1 })),
|
|
537
|
+
setTheme: (t: string) => set({ theme: t }),
|
|
538
|
+
}),
|
|
539
|
+
{ channel: 'my-app' }
|
|
540
|
+
)
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
// That's it — all tabs now share the same state.
|
|
544
|
+
// Functions (actions) are never synced, only data.
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
<details open>
|
|
548
|
+
<summary><b>📋 Middleware Options</b></summary>
|
|
549
|
+
|
|
550
|
+
<br />
|
|
551
|
+
|
|
552
|
+
| Option | Type | Default | Description |
|
|
553
|
+
|:-------|:-----|:--------|:------------|
|
|
554
|
+
| `channel` | `string` | `'tab-sync-zustand'` | Channel name for cross-tab communication |
|
|
555
|
+
| `include` | `string[]` | — | Only sync these keys (mutually exclusive with `exclude`) |
|
|
556
|
+
| `exclude` | `string[]` | — | Exclude these keys from syncing (mutually exclusive with `include`) |
|
|
557
|
+
| `merge` | `(local, remote, key) => value` | LWW | Custom conflict resolution |
|
|
558
|
+
| `transport` | `'broadcast-channel'` \| `'local-storage'` | auto | Force a specific transport |
|
|
559
|
+
| `debug` | `boolean` | `false` | Enable debug logging |
|
|
560
|
+
| `onError` | `(error) => void` | — | Error callback |
|
|
561
|
+
| `onSyncReady` | `(instance) => void` | — | Access the underlying `TabSyncInstance` for RPC/leader features |
|
|
562
|
+
|
|
563
|
+
</details>
|
|
564
|
+
|
|
565
|
+
<details>
|
|
566
|
+
<summary><b>🔑 Selective Key Sync</b></summary>
|
|
567
|
+
|
|
568
|
+
<br />
|
|
569
|
+
|
|
570
|
+
```ts
|
|
571
|
+
const useStore = create(
|
|
572
|
+
tabSync(
|
|
573
|
+
(set) => ({
|
|
574
|
+
count: 0,
|
|
575
|
+
theme: 'light',
|
|
576
|
+
localDraft: '', // won't be synced
|
|
577
|
+
inc: () => set((s) => ({ count: s.count + 1 })),
|
|
578
|
+
}),
|
|
579
|
+
{
|
|
580
|
+
channel: 'my-app',
|
|
581
|
+
exclude: ['localDraft'], // keep this key local-only
|
|
582
|
+
}
|
|
583
|
+
)
|
|
584
|
+
);
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
</details>
|
|
588
|
+
|
|
589
|
+
<details>
|
|
590
|
+
<summary><b>🤝 Works with Zustand <code>persist</code></b></summary>
|
|
591
|
+
|
|
592
|
+
<br />
|
|
593
|
+
|
|
594
|
+
Compose with Zustand's `persist` middleware — order doesn't matter:
|
|
595
|
+
|
|
596
|
+
```ts
|
|
597
|
+
import { persist } from 'zustand/middleware';
|
|
598
|
+
|
|
599
|
+
const useStore = create(
|
|
600
|
+
persist(
|
|
601
|
+
tabSync(
|
|
602
|
+
(set) => ({
|
|
603
|
+
count: 0,
|
|
604
|
+
inc: () => set((s) => ({ count: s.count + 1 })),
|
|
605
|
+
}),
|
|
606
|
+
{ channel: 'my-app' }
|
|
607
|
+
),
|
|
608
|
+
{ name: 'my-store' }
|
|
609
|
+
)
|
|
610
|
+
);
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
</details>
|
|
614
|
+
|
|
615
|
+
<details>
|
|
616
|
+
<summary><b>🚀 Advanced: Access tab-bridge Instance</b></summary>
|
|
617
|
+
|
|
618
|
+
<br />
|
|
619
|
+
|
|
620
|
+
Use `onSyncReady` to access the underlying `TabSyncInstance` for RPC, leader election, and other advanced features:
|
|
621
|
+
|
|
622
|
+
```ts
|
|
623
|
+
let syncInstance: TabSyncInstance | null = null;
|
|
624
|
+
|
|
625
|
+
const useStore = create(
|
|
626
|
+
tabSync(
|
|
627
|
+
(set) => ({ count: 0 }),
|
|
628
|
+
{
|
|
629
|
+
channel: 'my-app',
|
|
630
|
+
onSyncReady: (instance) => {
|
|
631
|
+
syncInstance = instance;
|
|
632
|
+
|
|
633
|
+
instance.handle('getCount', () => useStore.getState().count);
|
|
634
|
+
|
|
635
|
+
instance.onLeader(() => {
|
|
636
|
+
console.log('This tab is now the leader');
|
|
637
|
+
return () => console.log('Leadership lost');
|
|
638
|
+
});
|
|
639
|
+
},
|
|
640
|
+
}
|
|
641
|
+
)
|
|
642
|
+
);
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
</details>
|
|
646
|
+
|
|
647
|
+
<br />
|
|
648
|
+
|
|
649
|
+
---
|
|
650
|
+
|
|
651
|
+
<br />
|
|
652
|
+
|
|
653
|
+
## 📘 Next.js
|
|
654
|
+
|
|
655
|
+
Using tab-bridge with **Next.js App Router**? Since tab-bridge relies on browser APIs, all usage must be in Client Components.
|
|
656
|
+
|
|
657
|
+
```tsx
|
|
658
|
+
// app/providers/tab-sync-provider.tsx
|
|
659
|
+
'use client';
|
|
660
|
+
|
|
661
|
+
import { TabSyncProvider } from 'tab-bridge/react';
|
|
662
|
+
|
|
663
|
+
export function AppTabSyncProvider({ children }: { children: React.ReactNode }) {
|
|
664
|
+
return (
|
|
665
|
+
<TabSyncProvider options={{ initial: { count: 0 }, channel: 'my-app' }}>
|
|
666
|
+
{children}
|
|
667
|
+
</TabSyncProvider>
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
```tsx
|
|
673
|
+
// app/layout.tsx
|
|
674
|
+
import { AppTabSyncProvider } from './providers/tab-sync-provider';
|
|
675
|
+
|
|
676
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
677
|
+
return (
|
|
678
|
+
<html><body>
|
|
679
|
+
<AppTabSyncProvider>{children}</AppTabSyncProvider>
|
|
680
|
+
</body></html>
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
> **Full guide**: See [`docs/NEXTJS.md`](./docs/NEXTJS.md) for SSR safety patterns, hydration mismatch prevention, `useEffect` initialization, and Zustand integration with Next.js.
|
|
686
|
+
|
|
687
|
+
<br />
|
|
688
|
+
|
|
689
|
+
---
|
|
690
|
+
|
|
691
|
+
<br />
|
|
692
|
+
|
|
516
693
|
## 🚨 Error Handling
|
|
517
694
|
|
|
518
695
|
Structured errors with error codes for precise `catch` handling:
|
|
@@ -641,6 +818,19 @@ import {
|
|
|
641
818
|
|
|
642
819
|
## 💡 Examples
|
|
643
820
|
|
|
821
|
+
### 🎯 Interactive Demos
|
|
822
|
+
|
|
823
|
+
Try these demos live — open multiple tabs to see real-time synchronization in action:
|
|
824
|
+
|
|
825
|
+
| Demo | Description | Features |
|
|
826
|
+
|:-----|:-----------|:---------|
|
|
827
|
+
| [**Collaborative Editor**](https://serbi2012.github.io/tab-bridge/collaborative-editor.html) | Multi-tab real-time text editing | State Sync, Typing Indicators |
|
|
828
|
+
| [**Shopping Cart**](https://serbi2012.github.io/tab-bridge/shopping-cart.html) | Cart synced across all tabs + persistent | State Sync, Persistence |
|
|
829
|
+
| [**Leader Dashboard**](https://serbi2012.github.io/tab-bridge/leader-dashboard.html) | Only leader fetches data, followers use RPC | Leader Election, RPC, callAll |
|
|
830
|
+
| [**Full Feature Demo**](https://serbi2012.github.io/tab-bridge/) | All features in one page | Everything |
|
|
831
|
+
|
|
832
|
+
### Code Examples
|
|
833
|
+
|
|
644
834
|
<details>
|
|
645
835
|
<summary><b>🔐 Shared Authentication State</b></summary>
|
|
646
836
|
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { R as RPCMap, T as
|
|
2
|
-
export {
|
|
1
|
+
import { R as RPCMap, T as TabSyncInstance, C as ChangeMeta, a as TabInfo, b as RPCCallAllResult } from './instance-hvEUHx6i.cjs';
|
|
2
|
+
export { c as RPCArgs, d as RPCResult } from './instance-hvEUHx6i.cjs';
|
|
3
|
+
import { T as TabSyncOptions, M as Middleware, a as MiddlewareContext, P as PersistOptions } from './options-DmHyGTL0.cjs';
|
|
4
|
+
export { L as LeaderOptions, b as MiddlewareResult } from './options-DmHyGTL0.cjs';
|
|
3
5
|
|
|
4
6
|
declare const PROTOCOL_VERSION = 1;
|
|
5
7
|
interface StateUpdatePayload {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { R as RPCMap, T as
|
|
2
|
-
export {
|
|
1
|
+
import { R as RPCMap, T as TabSyncInstance, C as ChangeMeta, a as TabInfo, b as RPCCallAllResult } from './instance-hvEUHx6i.js';
|
|
2
|
+
export { c as RPCArgs, d as RPCResult } from './instance-hvEUHx6i.js';
|
|
3
|
+
import { T as TabSyncOptions, M as Middleware, a as MiddlewareContext, P as PersistOptions } from './options-iN7Rnvwj.js';
|
|
4
|
+
export { L as LeaderOptions, b as MiddlewareResult } from './options-iN7Rnvwj.js';
|
|
3
5
|
|
|
4
6
|
declare const PROTOCOL_VERSION = 1;
|
|
5
7
|
interface StateUpdatePayload {
|
|
@@ -13,65 +13,6 @@ interface ChangeMeta {
|
|
|
13
13
|
readonly timestamp: number;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
interface MiddlewareContext<TState extends Record<string, unknown>> {
|
|
17
|
-
readonly key: keyof TState;
|
|
18
|
-
readonly value: unknown;
|
|
19
|
-
readonly previousValue: unknown;
|
|
20
|
-
readonly meta: ChangeMeta;
|
|
21
|
-
}
|
|
22
|
-
/**
|
|
23
|
-
* Return `false` to reject the change, `{ value }` to transform it,
|
|
24
|
-
* or `void`/`undefined` to pass through unchanged.
|
|
25
|
-
*/
|
|
26
|
-
type MiddlewareResult = {
|
|
27
|
-
value: unknown;
|
|
28
|
-
} | false;
|
|
29
|
-
interface Middleware<TState extends Record<string, unknown> = Record<string, unknown>> {
|
|
30
|
-
readonly name: string;
|
|
31
|
-
/** Intercept local `set` / `patch` calls before they are applied. */
|
|
32
|
-
onSet?: (ctx: MiddlewareContext<TState>) => MiddlewareResult | void;
|
|
33
|
-
/** Called after any state change (local or remote) has been committed. */
|
|
34
|
-
afterChange?: (key: keyof TState, value: unknown, meta: ChangeMeta) => void;
|
|
35
|
-
/** Cleanup when the instance is destroyed. */
|
|
36
|
-
onDestroy?: () => void;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
interface PersistOptions<TState extends Record<string, unknown> = Record<string, unknown>> {
|
|
40
|
-
/** Storage key. Default: `'tab-sync:state'` */
|
|
41
|
-
key?: string;
|
|
42
|
-
/** Only persist these keys (whitelist). */
|
|
43
|
-
include?: (keyof TState)[];
|
|
44
|
-
/** Exclude these keys from persistence (blacklist). */
|
|
45
|
-
exclude?: (keyof TState)[];
|
|
46
|
-
/** Custom serializer. Default: `JSON.stringify` */
|
|
47
|
-
serialize?: (state: Partial<TState>) => string;
|
|
48
|
-
/** Custom deserializer. Default: `JSON.parse` */
|
|
49
|
-
deserialize?: (raw: string) => Partial<TState>;
|
|
50
|
-
/** Debounce persistence writes in ms. Default: `100` */
|
|
51
|
-
debounce?: number;
|
|
52
|
-
/** Custom storage backend. Default: `localStorage` */
|
|
53
|
-
storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
|
54
|
-
/**
|
|
55
|
-
* Schema version for state migration. When the persisted version
|
|
56
|
-
* differs from this value, `migrate` is called.
|
|
57
|
-
*/
|
|
58
|
-
version?: number;
|
|
59
|
-
/**
|
|
60
|
-
* Migration function called when persisted version differs from current.
|
|
61
|
-
*
|
|
62
|
-
* ```ts
|
|
63
|
-
* persist: {
|
|
64
|
-
* version: 2,
|
|
65
|
-
* migrate: (oldState, oldVersion) => ({
|
|
66
|
-
* ...oldState,
|
|
67
|
-
* newField: oldVersion < 2 ? 'default' : oldState.newField,
|
|
68
|
-
* }),
|
|
69
|
-
* }
|
|
70
|
-
* ```
|
|
71
|
-
*/
|
|
72
|
-
migrate?: (oldState: Partial<TState>, oldVersion: number) => Partial<TState>;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
16
|
/**
|
|
76
17
|
* Define your RPC contract for full type inference:
|
|
77
18
|
*
|
|
@@ -99,37 +40,6 @@ type RPCArgs<TMap extends RPCMap, M extends string> = M extends keyof TMap ? TMa
|
|
|
99
40
|
/** Resolve result type for a method. Falls back to `unknown` for unregistered methods. */
|
|
100
41
|
type RPCResult<TMap extends RPCMap, M extends string> = M extends keyof TMap ? TMap[M]['result'] : unknown;
|
|
101
42
|
|
|
102
|
-
interface LeaderOptions {
|
|
103
|
-
/** Heartbeat interval in ms. Default: `2000` */
|
|
104
|
-
heartbeatInterval?: number;
|
|
105
|
-
/** Leader timeout in ms. Default: `6000` */
|
|
106
|
-
leaderTimeout?: number;
|
|
107
|
-
}
|
|
108
|
-
interface TabSyncOptions<TState extends Record<string, unknown> = Record<string, unknown>> {
|
|
109
|
-
/** Initial state used before sync completes. */
|
|
110
|
-
initial?: TState;
|
|
111
|
-
/** Channel name — only tabs sharing the same name communicate. Default: `'tab-sync'` */
|
|
112
|
-
channel?: string;
|
|
113
|
-
/** Force a specific transport layer. Default: auto-detect. */
|
|
114
|
-
transport?: 'broadcast-channel' | 'local-storage';
|
|
115
|
-
/** Custom merge function for LWW conflict resolution. */
|
|
116
|
-
merge?: (localValue: unknown, remoteValue: unknown, key: keyof TState) => unknown;
|
|
117
|
-
/** Enable leader election. Default: `true` */
|
|
118
|
-
leader?: boolean | LeaderOptions;
|
|
119
|
-
/** Heartbeat interval in ms. Default: `2000` */
|
|
120
|
-
heartbeatInterval?: number;
|
|
121
|
-
/** Leader timeout in ms. Default: `6000` */
|
|
122
|
-
leaderTimeout?: number;
|
|
123
|
-
/** Enable debug logging. Default: `false` */
|
|
124
|
-
debug?: boolean;
|
|
125
|
-
/** Persist state across page reloads. `true` uses defaults, or pass options. */
|
|
126
|
-
persist?: PersistOptions<TState> | boolean;
|
|
127
|
-
/** Middleware pipeline for intercepting state changes. */
|
|
128
|
-
middlewares?: Middleware<TState>[];
|
|
129
|
-
/** Error callback for non-fatal errors (storage, channel, etc.). */
|
|
130
|
-
onError?: (error: Error) => void;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
43
|
interface TabSyncInstance<TState extends Record<string, unknown>, TRPCMap extends RPCMap = RPCMap> {
|
|
134
44
|
/** Read a single value by key. */
|
|
135
45
|
get<K extends keyof TState>(key: K): TState[K];
|
|
@@ -281,4 +191,4 @@ interface TabSyncInstance<TState extends Record<string, unknown>, TRPCMap extend
|
|
|
281
191
|
readonly ready: boolean;
|
|
282
192
|
}
|
|
283
193
|
|
|
284
|
-
export type { ChangeMeta as C,
|
|
194
|
+
export type { ChangeMeta as C, RPCMap as R, TabSyncInstance as T, TabInfo as a, RPCCallAllResult as b, RPCArgs as c, RPCResult as d };
|
|
@@ -13,65 +13,6 @@ interface ChangeMeta {
|
|
|
13
13
|
readonly timestamp: number;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
interface MiddlewareContext<TState extends Record<string, unknown>> {
|
|
17
|
-
readonly key: keyof TState;
|
|
18
|
-
readonly value: unknown;
|
|
19
|
-
readonly previousValue: unknown;
|
|
20
|
-
readonly meta: ChangeMeta;
|
|
21
|
-
}
|
|
22
|
-
/**
|
|
23
|
-
* Return `false` to reject the change, `{ value }` to transform it,
|
|
24
|
-
* or `void`/`undefined` to pass through unchanged.
|
|
25
|
-
*/
|
|
26
|
-
type MiddlewareResult = {
|
|
27
|
-
value: unknown;
|
|
28
|
-
} | false;
|
|
29
|
-
interface Middleware<TState extends Record<string, unknown> = Record<string, unknown>> {
|
|
30
|
-
readonly name: string;
|
|
31
|
-
/** Intercept local `set` / `patch` calls before they are applied. */
|
|
32
|
-
onSet?: (ctx: MiddlewareContext<TState>) => MiddlewareResult | void;
|
|
33
|
-
/** Called after any state change (local or remote) has been committed. */
|
|
34
|
-
afterChange?: (key: keyof TState, value: unknown, meta: ChangeMeta) => void;
|
|
35
|
-
/** Cleanup when the instance is destroyed. */
|
|
36
|
-
onDestroy?: () => void;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
interface PersistOptions<TState extends Record<string, unknown> = Record<string, unknown>> {
|
|
40
|
-
/** Storage key. Default: `'tab-sync:state'` */
|
|
41
|
-
key?: string;
|
|
42
|
-
/** Only persist these keys (whitelist). */
|
|
43
|
-
include?: (keyof TState)[];
|
|
44
|
-
/** Exclude these keys from persistence (blacklist). */
|
|
45
|
-
exclude?: (keyof TState)[];
|
|
46
|
-
/** Custom serializer. Default: `JSON.stringify` */
|
|
47
|
-
serialize?: (state: Partial<TState>) => string;
|
|
48
|
-
/** Custom deserializer. Default: `JSON.parse` */
|
|
49
|
-
deserialize?: (raw: string) => Partial<TState>;
|
|
50
|
-
/** Debounce persistence writes in ms. Default: `100` */
|
|
51
|
-
debounce?: number;
|
|
52
|
-
/** Custom storage backend. Default: `localStorage` */
|
|
53
|
-
storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
|
54
|
-
/**
|
|
55
|
-
* Schema version for state migration. When the persisted version
|
|
56
|
-
* differs from this value, `migrate` is called.
|
|
57
|
-
*/
|
|
58
|
-
version?: number;
|
|
59
|
-
/**
|
|
60
|
-
* Migration function called when persisted version differs from current.
|
|
61
|
-
*
|
|
62
|
-
* ```ts
|
|
63
|
-
* persist: {
|
|
64
|
-
* version: 2,
|
|
65
|
-
* migrate: (oldState, oldVersion) => ({
|
|
66
|
-
* ...oldState,
|
|
67
|
-
* newField: oldVersion < 2 ? 'default' : oldState.newField,
|
|
68
|
-
* }),
|
|
69
|
-
* }
|
|
70
|
-
* ```
|
|
71
|
-
*/
|
|
72
|
-
migrate?: (oldState: Partial<TState>, oldVersion: number) => Partial<TState>;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
16
|
/**
|
|
76
17
|
* Define your RPC contract for full type inference:
|
|
77
18
|
*
|
|
@@ -99,37 +40,6 @@ type RPCArgs<TMap extends RPCMap, M extends string> = M extends keyof TMap ? TMa
|
|
|
99
40
|
/** Resolve result type for a method. Falls back to `unknown` for unregistered methods. */
|
|
100
41
|
type RPCResult<TMap extends RPCMap, M extends string> = M extends keyof TMap ? TMap[M]['result'] : unknown;
|
|
101
42
|
|
|
102
|
-
interface LeaderOptions {
|
|
103
|
-
/** Heartbeat interval in ms. Default: `2000` */
|
|
104
|
-
heartbeatInterval?: number;
|
|
105
|
-
/** Leader timeout in ms. Default: `6000` */
|
|
106
|
-
leaderTimeout?: number;
|
|
107
|
-
}
|
|
108
|
-
interface TabSyncOptions<TState extends Record<string, unknown> = Record<string, unknown>> {
|
|
109
|
-
/** Initial state used before sync completes. */
|
|
110
|
-
initial?: TState;
|
|
111
|
-
/** Channel name — only tabs sharing the same name communicate. Default: `'tab-sync'` */
|
|
112
|
-
channel?: string;
|
|
113
|
-
/** Force a specific transport layer. Default: auto-detect. */
|
|
114
|
-
transport?: 'broadcast-channel' | 'local-storage';
|
|
115
|
-
/** Custom merge function for LWW conflict resolution. */
|
|
116
|
-
merge?: (localValue: unknown, remoteValue: unknown, key: keyof TState) => unknown;
|
|
117
|
-
/** Enable leader election. Default: `true` */
|
|
118
|
-
leader?: boolean | LeaderOptions;
|
|
119
|
-
/** Heartbeat interval in ms. Default: `2000` */
|
|
120
|
-
heartbeatInterval?: number;
|
|
121
|
-
/** Leader timeout in ms. Default: `6000` */
|
|
122
|
-
leaderTimeout?: number;
|
|
123
|
-
/** Enable debug logging. Default: `false` */
|
|
124
|
-
debug?: boolean;
|
|
125
|
-
/** Persist state across page reloads. `true` uses defaults, or pass options. */
|
|
126
|
-
persist?: PersistOptions<TState> | boolean;
|
|
127
|
-
/** Middleware pipeline for intercepting state changes. */
|
|
128
|
-
middlewares?: Middleware<TState>[];
|
|
129
|
-
/** Error callback for non-fatal errors (storage, channel, etc.). */
|
|
130
|
-
onError?: (error: Error) => void;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
43
|
interface TabSyncInstance<TState extends Record<string, unknown>, TRPCMap extends RPCMap = RPCMap> {
|
|
134
44
|
/** Read a single value by key. */
|
|
135
45
|
get<K extends keyof TState>(key: K): TState[K];
|
|
@@ -281,4 +191,4 @@ interface TabSyncInstance<TState extends Record<string, unknown>, TRPCMap extend
|
|
|
281
191
|
readonly ready: boolean;
|
|
282
192
|
}
|
|
283
193
|
|
|
284
|
-
export type { ChangeMeta as C,
|
|
194
|
+
export type { ChangeMeta as C, RPCMap as R, TabSyncInstance as T, TabInfo as a, RPCCallAllResult as b, RPCArgs as c, RPCResult as d };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { C as ChangeMeta } from './instance-hvEUHx6i.cjs';
|
|
2
|
+
|
|
3
|
+
interface MiddlewareContext<TState extends Record<string, unknown>> {
|
|
4
|
+
readonly key: keyof TState;
|
|
5
|
+
readonly value: unknown;
|
|
6
|
+
readonly previousValue: unknown;
|
|
7
|
+
readonly meta: ChangeMeta;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Return `false` to reject the change, `{ value }` to transform it,
|
|
11
|
+
* or `void`/`undefined` to pass through unchanged.
|
|
12
|
+
*/
|
|
13
|
+
type MiddlewareResult = {
|
|
14
|
+
value: unknown;
|
|
15
|
+
} | false;
|
|
16
|
+
interface Middleware<TState extends Record<string, unknown> = Record<string, unknown>> {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
/** Intercept local `set` / `patch` calls before they are applied. */
|
|
19
|
+
onSet?: (ctx: MiddlewareContext<TState>) => MiddlewareResult | void;
|
|
20
|
+
/** Called after any state change (local or remote) has been committed. */
|
|
21
|
+
afterChange?: (key: keyof TState, value: unknown, meta: ChangeMeta) => void;
|
|
22
|
+
/** Cleanup when the instance is destroyed. */
|
|
23
|
+
onDestroy?: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface PersistOptions<TState extends Record<string, unknown> = Record<string, unknown>> {
|
|
27
|
+
/** Storage key. Default: `'tab-sync:state'` */
|
|
28
|
+
key?: string;
|
|
29
|
+
/** Only persist these keys (whitelist). */
|
|
30
|
+
include?: (keyof TState)[];
|
|
31
|
+
/** Exclude these keys from persistence (blacklist). */
|
|
32
|
+
exclude?: (keyof TState)[];
|
|
33
|
+
/** Custom serializer. Default: `JSON.stringify` */
|
|
34
|
+
serialize?: (state: Partial<TState>) => string;
|
|
35
|
+
/** Custom deserializer. Default: `JSON.parse` */
|
|
36
|
+
deserialize?: (raw: string) => Partial<TState>;
|
|
37
|
+
/** Debounce persistence writes in ms. Default: `100` */
|
|
38
|
+
debounce?: number;
|
|
39
|
+
/** Custom storage backend. Default: `localStorage` */
|
|
40
|
+
storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
|
41
|
+
/**
|
|
42
|
+
* Schema version for state migration. When the persisted version
|
|
43
|
+
* differs from this value, `migrate` is called.
|
|
44
|
+
*/
|
|
45
|
+
version?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Migration function called when persisted version differs from current.
|
|
48
|
+
*
|
|
49
|
+
* ```ts
|
|
50
|
+
* persist: {
|
|
51
|
+
* version: 2,
|
|
52
|
+
* migrate: (oldState, oldVersion) => ({
|
|
53
|
+
* ...oldState,
|
|
54
|
+
* newField: oldVersion < 2 ? 'default' : oldState.newField,
|
|
55
|
+
* }),
|
|
56
|
+
* }
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
migrate?: (oldState: Partial<TState>, oldVersion: number) => Partial<TState>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface LeaderOptions {
|
|
63
|
+
/** Heartbeat interval in ms. Default: `2000` */
|
|
64
|
+
heartbeatInterval?: number;
|
|
65
|
+
/** Leader timeout in ms. Default: `6000` */
|
|
66
|
+
leaderTimeout?: number;
|
|
67
|
+
}
|
|
68
|
+
interface TabSyncOptions<TState extends Record<string, unknown> = Record<string, unknown>> {
|
|
69
|
+
/** Initial state used before sync completes. */
|
|
70
|
+
initial?: TState;
|
|
71
|
+
/** Channel name — only tabs sharing the same name communicate. Default: `'tab-sync'` */
|
|
72
|
+
channel?: string;
|
|
73
|
+
/** Force a specific transport layer. Default: auto-detect. */
|
|
74
|
+
transport?: 'broadcast-channel' | 'local-storage';
|
|
75
|
+
/** Custom merge function for LWW conflict resolution. */
|
|
76
|
+
merge?: (localValue: unknown, remoteValue: unknown, key: keyof TState) => unknown;
|
|
77
|
+
/** Enable leader election. Default: `true` */
|
|
78
|
+
leader?: boolean | LeaderOptions;
|
|
79
|
+
/** Heartbeat interval in ms. Default: `2000` */
|
|
80
|
+
heartbeatInterval?: number;
|
|
81
|
+
/** Leader timeout in ms. Default: `6000` */
|
|
82
|
+
leaderTimeout?: number;
|
|
83
|
+
/** Enable debug logging. Default: `false` */
|
|
84
|
+
debug?: boolean;
|
|
85
|
+
/** Persist state across page reloads. `true` uses defaults, or pass options. */
|
|
86
|
+
persist?: PersistOptions<TState> | boolean;
|
|
87
|
+
/** Middleware pipeline for intercepting state changes. */
|
|
88
|
+
middlewares?: Middleware<TState>[];
|
|
89
|
+
/** Error callback for non-fatal errors (storage, channel, etc.). */
|
|
90
|
+
onError?: (error: Error) => void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type { LeaderOptions as L, Middleware as M, PersistOptions as P, TabSyncOptions as T, MiddlewareContext as a, MiddlewareResult as b };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { C as ChangeMeta } from './instance-hvEUHx6i.js';
|
|
2
|
+
|
|
3
|
+
interface MiddlewareContext<TState extends Record<string, unknown>> {
|
|
4
|
+
readonly key: keyof TState;
|
|
5
|
+
readonly value: unknown;
|
|
6
|
+
readonly previousValue: unknown;
|
|
7
|
+
readonly meta: ChangeMeta;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Return `false` to reject the change, `{ value }` to transform it,
|
|
11
|
+
* or `void`/`undefined` to pass through unchanged.
|
|
12
|
+
*/
|
|
13
|
+
type MiddlewareResult = {
|
|
14
|
+
value: unknown;
|
|
15
|
+
} | false;
|
|
16
|
+
interface Middleware<TState extends Record<string, unknown> = Record<string, unknown>> {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
/** Intercept local `set` / `patch` calls before they are applied. */
|
|
19
|
+
onSet?: (ctx: MiddlewareContext<TState>) => MiddlewareResult | void;
|
|
20
|
+
/** Called after any state change (local or remote) has been committed. */
|
|
21
|
+
afterChange?: (key: keyof TState, value: unknown, meta: ChangeMeta) => void;
|
|
22
|
+
/** Cleanup when the instance is destroyed. */
|
|
23
|
+
onDestroy?: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface PersistOptions<TState extends Record<string, unknown> = Record<string, unknown>> {
|
|
27
|
+
/** Storage key. Default: `'tab-sync:state'` */
|
|
28
|
+
key?: string;
|
|
29
|
+
/** Only persist these keys (whitelist). */
|
|
30
|
+
include?: (keyof TState)[];
|
|
31
|
+
/** Exclude these keys from persistence (blacklist). */
|
|
32
|
+
exclude?: (keyof TState)[];
|
|
33
|
+
/** Custom serializer. Default: `JSON.stringify` */
|
|
34
|
+
serialize?: (state: Partial<TState>) => string;
|
|
35
|
+
/** Custom deserializer. Default: `JSON.parse` */
|
|
36
|
+
deserialize?: (raw: string) => Partial<TState>;
|
|
37
|
+
/** Debounce persistence writes in ms. Default: `100` */
|
|
38
|
+
debounce?: number;
|
|
39
|
+
/** Custom storage backend. Default: `localStorage` */
|
|
40
|
+
storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
|
41
|
+
/**
|
|
42
|
+
* Schema version for state migration. When the persisted version
|
|
43
|
+
* differs from this value, `migrate` is called.
|
|
44
|
+
*/
|
|
45
|
+
version?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Migration function called when persisted version differs from current.
|
|
48
|
+
*
|
|
49
|
+
* ```ts
|
|
50
|
+
* persist: {
|
|
51
|
+
* version: 2,
|
|
52
|
+
* migrate: (oldState, oldVersion) => ({
|
|
53
|
+
* ...oldState,
|
|
54
|
+
* newField: oldVersion < 2 ? 'default' : oldState.newField,
|
|
55
|
+
* }),
|
|
56
|
+
* }
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
migrate?: (oldState: Partial<TState>, oldVersion: number) => Partial<TState>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface LeaderOptions {
|
|
63
|
+
/** Heartbeat interval in ms. Default: `2000` */
|
|
64
|
+
heartbeatInterval?: number;
|
|
65
|
+
/** Leader timeout in ms. Default: `6000` */
|
|
66
|
+
leaderTimeout?: number;
|
|
67
|
+
}
|
|
68
|
+
interface TabSyncOptions<TState extends Record<string, unknown> = Record<string, unknown>> {
|
|
69
|
+
/** Initial state used before sync completes. */
|
|
70
|
+
initial?: TState;
|
|
71
|
+
/** Channel name — only tabs sharing the same name communicate. Default: `'tab-sync'` */
|
|
72
|
+
channel?: string;
|
|
73
|
+
/** Force a specific transport layer. Default: auto-detect. */
|
|
74
|
+
transport?: 'broadcast-channel' | 'local-storage';
|
|
75
|
+
/** Custom merge function for LWW conflict resolution. */
|
|
76
|
+
merge?: (localValue: unknown, remoteValue: unknown, key: keyof TState) => unknown;
|
|
77
|
+
/** Enable leader election. Default: `true` */
|
|
78
|
+
leader?: boolean | LeaderOptions;
|
|
79
|
+
/** Heartbeat interval in ms. Default: `2000` */
|
|
80
|
+
heartbeatInterval?: number;
|
|
81
|
+
/** Leader timeout in ms. Default: `6000` */
|
|
82
|
+
leaderTimeout?: number;
|
|
83
|
+
/** Enable debug logging. Default: `false` */
|
|
84
|
+
debug?: boolean;
|
|
85
|
+
/** Persist state across page reloads. `true` uses defaults, or pass options. */
|
|
86
|
+
persist?: PersistOptions<TState> | boolean;
|
|
87
|
+
/** Middleware pipeline for intercepting state changes. */
|
|
88
|
+
middlewares?: Middleware<TState>[];
|
|
89
|
+
/** Error callback for non-fatal errors (storage, channel, etc.). */
|
|
90
|
+
onError?: (error: Error) => void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type { LeaderOptions as L, Middleware as M, PersistOptions as P, TabSyncOptions as T, MiddlewareContext as a, MiddlewareResult as b };
|
package/dist/react/index.d.cts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
2
|
import * as react from 'react';
|
|
3
3
|
import { ReactNode } from 'react';
|
|
4
|
-
import { T as TabSyncOptions
|
|
4
|
+
import { T as TabSyncOptions } from '../options-DmHyGTL0.cjs';
|
|
5
|
+
import { T as TabSyncInstance, R as RPCMap, a as TabInfo } from '../instance-hvEUHx6i.cjs';
|
|
5
6
|
|
|
6
7
|
interface TabSyncProviderProps<TState extends Record<string, unknown>> {
|
|
7
8
|
options: TabSyncOptions<TState>;
|
package/dist/react/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
2
|
import * as react from 'react';
|
|
3
3
|
import { ReactNode } from 'react';
|
|
4
|
-
import { T as TabSyncOptions
|
|
4
|
+
import { T as TabSyncOptions } from '../options-iN7Rnvwj.js';
|
|
5
|
+
import { T as TabSyncInstance, R as RPCMap, a as TabInfo } from '../instance-hvEUHx6i.js';
|
|
5
6
|
|
|
6
7
|
interface TabSyncProviderProps<TState extends Record<string, unknown>> {
|
|
7
8
|
options: TabSyncOptions<TState>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var chunkTGEXRVAL_cjs = require('../chunk-TGEXRVAL.cjs');
|
|
4
|
+
|
|
5
|
+
// src/zustand/middleware.ts
|
|
6
|
+
function shouldSyncKey(key, value, options) {
|
|
7
|
+
if (typeof value === "function") return false;
|
|
8
|
+
if (options?.include) return options.include.includes(key);
|
|
9
|
+
if (options?.exclude) return !options.exclude.includes(key);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
function extractSyncableState(state, options) {
|
|
13
|
+
const result = {};
|
|
14
|
+
for (const [key, value] of Object.entries(state)) {
|
|
15
|
+
if (shouldSyncKey(key, value, options)) {
|
|
16
|
+
result[key] = value;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
function validateOptions(options) {
|
|
22
|
+
if (options?.include && options?.exclude) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
"[tab-bridge/zustand] `include` and `exclude` are mutually exclusive"
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
var tabSyncImpl = (initializer, options) => (set, get, api) => {
|
|
29
|
+
validateOptions(options);
|
|
30
|
+
let sync_instance = null;
|
|
31
|
+
let is_remote_update = false;
|
|
32
|
+
const initial_state = initializer(set, get, api);
|
|
33
|
+
const syncable_initial = extractSyncableState(initial_state, options);
|
|
34
|
+
sync_instance = chunkTGEXRVAL_cjs.createTabSync({
|
|
35
|
+
initial: syncable_initial,
|
|
36
|
+
channel: options?.channel ?? "tab-sync-zustand",
|
|
37
|
+
debug: options?.debug,
|
|
38
|
+
transport: options?.transport,
|
|
39
|
+
merge: options?.merge,
|
|
40
|
+
onError: options?.onError
|
|
41
|
+
});
|
|
42
|
+
api.subscribe((next_state, prev_state) => {
|
|
43
|
+
if (is_remote_update || !sync_instance) return;
|
|
44
|
+
const next = next_state;
|
|
45
|
+
const prev = prev_state;
|
|
46
|
+
const diff = {};
|
|
47
|
+
let has_diff = false;
|
|
48
|
+
for (const key of Object.keys(next)) {
|
|
49
|
+
if (shouldSyncKey(key, next[key], options) && !Object.is(prev[key], next[key])) {
|
|
50
|
+
diff[key] = next[key];
|
|
51
|
+
has_diff = true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (has_diff) {
|
|
55
|
+
sync_instance.patch(diff);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
sync_instance.onChange((remote_state, changed_keys, meta) => {
|
|
59
|
+
if (meta.isLocal) return;
|
|
60
|
+
is_remote_update = true;
|
|
61
|
+
try {
|
|
62
|
+
const patch = {};
|
|
63
|
+
let has_patch = false;
|
|
64
|
+
for (const key of changed_keys) {
|
|
65
|
+
patch[key] = remote_state[key];
|
|
66
|
+
has_patch = true;
|
|
67
|
+
}
|
|
68
|
+
if (has_patch) {
|
|
69
|
+
api.setState(patch);
|
|
70
|
+
}
|
|
71
|
+
} finally {
|
|
72
|
+
is_remote_update = false;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
options?.onSyncReady?.(sync_instance);
|
|
76
|
+
return initial_state;
|
|
77
|
+
};
|
|
78
|
+
var tabSync = tabSyncImpl;
|
|
79
|
+
|
|
80
|
+
exports.tabSync = tabSync;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { StoreMutatorIdentifier, StateCreator } from 'zustand/vanilla';
|
|
2
|
+
import { T as TabSyncInstance } from '../instance-hvEUHx6i.cjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Options for the `tabSync` Zustand middleware.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { create } from 'zustand';
|
|
10
|
+
* import { tabSync } from 'tab-bridge/zustand';
|
|
11
|
+
*
|
|
12
|
+
* const useStore = create(
|
|
13
|
+
* tabSync(
|
|
14
|
+
* (set) => ({
|
|
15
|
+
* count: 0,
|
|
16
|
+
* inc: () => set((s) => ({ count: s.count + 1 })),
|
|
17
|
+
* }),
|
|
18
|
+
* { channel: 'my-app', exclude: ['localOnly'] }
|
|
19
|
+
* )
|
|
20
|
+
* );
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
interface TabSyncZustandOptions<T = Record<string, unknown>> {
|
|
24
|
+
/** Channel name for cross-tab communication. @default 'tab-sync-zustand' */
|
|
25
|
+
channel?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Only sync these keys. Functions are always excluded.
|
|
28
|
+
* Mutually exclusive with `exclude`.
|
|
29
|
+
*/
|
|
30
|
+
include?: readonly (keyof T & string)[];
|
|
31
|
+
/**
|
|
32
|
+
* Exclude these keys from syncing. Functions are always excluded.
|
|
33
|
+
* Mutually exclusive with `include`.
|
|
34
|
+
*/
|
|
35
|
+
exclude?: readonly (keyof T & string)[];
|
|
36
|
+
/**
|
|
37
|
+
* Custom conflict resolution.
|
|
38
|
+
* Called when the same key is updated on two tabs simultaneously.
|
|
39
|
+
* @default Last-write-wins (LWW)
|
|
40
|
+
*/
|
|
41
|
+
merge?: (localValue: unknown, remoteValue: unknown, key: string) => unknown;
|
|
42
|
+
/** Force a specific transport layer. @default auto-detect */
|
|
43
|
+
transport?: 'broadcast-channel' | 'local-storage';
|
|
44
|
+
/** Enable debug logging. @default false */
|
|
45
|
+
debug?: boolean;
|
|
46
|
+
/** Error callback for non-fatal errors (channel failures, etc.). */
|
|
47
|
+
onError?: (error: Error) => void;
|
|
48
|
+
/**
|
|
49
|
+
* Callback invoked when the underlying `TabSyncInstance` is ready.
|
|
50
|
+
* Use this to access advanced features (RPC, leader election, etc.)
|
|
51
|
+
* or to store a reference for manual cleanup via `instance.destroy()`.
|
|
52
|
+
*/
|
|
53
|
+
onSyncReady?: (instance: TabSyncInstance<Record<string, unknown>>) => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type TabSyncMiddleware = <T, Mps extends [StoreMutatorIdentifier, unknown][] = [], Mcs extends [StoreMutatorIdentifier, unknown][] = []>(initializer: StateCreator<T, Mps, Mcs>, options?: TabSyncZustandOptions<T>) => StateCreator<T, Mps, Mcs>;
|
|
57
|
+
/**
|
|
58
|
+
* Zustand middleware that synchronizes store state across browser tabs
|
|
59
|
+
* via tab-bridge's BroadcastChannel/localStorage transport.
|
|
60
|
+
*
|
|
61
|
+
* Functions (actions) are automatically excluded from synchronization.
|
|
62
|
+
* Use `include` or `exclude` options to further filter which keys are synced.
|
|
63
|
+
*
|
|
64
|
+
* @param initializer - Zustand StateCreator function.
|
|
65
|
+
* @param options - Synchronization options.
|
|
66
|
+
* @returns A wrapped StateCreator that synchronizes state across tabs.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```ts
|
|
70
|
+
* import { create } from 'zustand';
|
|
71
|
+
* import { tabSync } from 'tab-bridge/zustand';
|
|
72
|
+
*
|
|
73
|
+
* const useStore = create(
|
|
74
|
+
* tabSync(
|
|
75
|
+
* (set) => ({
|
|
76
|
+
* count: 0,
|
|
77
|
+
* inc: () => set((s) => ({ count: s.count + 1 })),
|
|
78
|
+
* }),
|
|
79
|
+
* { channel: 'my-app' }
|
|
80
|
+
* )
|
|
81
|
+
* );
|
|
82
|
+
* // All tabs sharing channel 'my-app' will have synchronized state.
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
declare const tabSync: TabSyncMiddleware;
|
|
86
|
+
|
|
87
|
+
export { type TabSyncZustandOptions, tabSync };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { StoreMutatorIdentifier, StateCreator } from 'zustand/vanilla';
|
|
2
|
+
import { T as TabSyncInstance } from '../instance-hvEUHx6i.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Options for the `tabSync` Zustand middleware.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { create } from 'zustand';
|
|
10
|
+
* import { tabSync } from 'tab-bridge/zustand';
|
|
11
|
+
*
|
|
12
|
+
* const useStore = create(
|
|
13
|
+
* tabSync(
|
|
14
|
+
* (set) => ({
|
|
15
|
+
* count: 0,
|
|
16
|
+
* inc: () => set((s) => ({ count: s.count + 1 })),
|
|
17
|
+
* }),
|
|
18
|
+
* { channel: 'my-app', exclude: ['localOnly'] }
|
|
19
|
+
* )
|
|
20
|
+
* );
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
interface TabSyncZustandOptions<T = Record<string, unknown>> {
|
|
24
|
+
/** Channel name for cross-tab communication. @default 'tab-sync-zustand' */
|
|
25
|
+
channel?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Only sync these keys. Functions are always excluded.
|
|
28
|
+
* Mutually exclusive with `exclude`.
|
|
29
|
+
*/
|
|
30
|
+
include?: readonly (keyof T & string)[];
|
|
31
|
+
/**
|
|
32
|
+
* Exclude these keys from syncing. Functions are always excluded.
|
|
33
|
+
* Mutually exclusive with `include`.
|
|
34
|
+
*/
|
|
35
|
+
exclude?: readonly (keyof T & string)[];
|
|
36
|
+
/**
|
|
37
|
+
* Custom conflict resolution.
|
|
38
|
+
* Called when the same key is updated on two tabs simultaneously.
|
|
39
|
+
* @default Last-write-wins (LWW)
|
|
40
|
+
*/
|
|
41
|
+
merge?: (localValue: unknown, remoteValue: unknown, key: string) => unknown;
|
|
42
|
+
/** Force a specific transport layer. @default auto-detect */
|
|
43
|
+
transport?: 'broadcast-channel' | 'local-storage';
|
|
44
|
+
/** Enable debug logging. @default false */
|
|
45
|
+
debug?: boolean;
|
|
46
|
+
/** Error callback for non-fatal errors (channel failures, etc.). */
|
|
47
|
+
onError?: (error: Error) => void;
|
|
48
|
+
/**
|
|
49
|
+
* Callback invoked when the underlying `TabSyncInstance` is ready.
|
|
50
|
+
* Use this to access advanced features (RPC, leader election, etc.)
|
|
51
|
+
* or to store a reference for manual cleanup via `instance.destroy()`.
|
|
52
|
+
*/
|
|
53
|
+
onSyncReady?: (instance: TabSyncInstance<Record<string, unknown>>) => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type TabSyncMiddleware = <T, Mps extends [StoreMutatorIdentifier, unknown][] = [], Mcs extends [StoreMutatorIdentifier, unknown][] = []>(initializer: StateCreator<T, Mps, Mcs>, options?: TabSyncZustandOptions<T>) => StateCreator<T, Mps, Mcs>;
|
|
57
|
+
/**
|
|
58
|
+
* Zustand middleware that synchronizes store state across browser tabs
|
|
59
|
+
* via tab-bridge's BroadcastChannel/localStorage transport.
|
|
60
|
+
*
|
|
61
|
+
* Functions (actions) are automatically excluded from synchronization.
|
|
62
|
+
* Use `include` or `exclude` options to further filter which keys are synced.
|
|
63
|
+
*
|
|
64
|
+
* @param initializer - Zustand StateCreator function.
|
|
65
|
+
* @param options - Synchronization options.
|
|
66
|
+
* @returns A wrapped StateCreator that synchronizes state across tabs.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```ts
|
|
70
|
+
* import { create } from 'zustand';
|
|
71
|
+
* import { tabSync } from 'tab-bridge/zustand';
|
|
72
|
+
*
|
|
73
|
+
* const useStore = create(
|
|
74
|
+
* tabSync(
|
|
75
|
+
* (set) => ({
|
|
76
|
+
* count: 0,
|
|
77
|
+
* inc: () => set((s) => ({ count: s.count + 1 })),
|
|
78
|
+
* }),
|
|
79
|
+
* { channel: 'my-app' }
|
|
80
|
+
* )
|
|
81
|
+
* );
|
|
82
|
+
* // All tabs sharing channel 'my-app' will have synchronized state.
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
declare const tabSync: TabSyncMiddleware;
|
|
86
|
+
|
|
87
|
+
export { type TabSyncZustandOptions, tabSync };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createTabSync } from '../chunk-4JDWAUYM.js';
|
|
2
|
+
|
|
3
|
+
// src/zustand/middleware.ts
|
|
4
|
+
function shouldSyncKey(key, value, options) {
|
|
5
|
+
if (typeof value === "function") return false;
|
|
6
|
+
if (options?.include) return options.include.includes(key);
|
|
7
|
+
if (options?.exclude) return !options.exclude.includes(key);
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
function extractSyncableState(state, options) {
|
|
11
|
+
const result = {};
|
|
12
|
+
for (const [key, value] of Object.entries(state)) {
|
|
13
|
+
if (shouldSyncKey(key, value, options)) {
|
|
14
|
+
result[key] = value;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
function validateOptions(options) {
|
|
20
|
+
if (options?.include && options?.exclude) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
"[tab-bridge/zustand] `include` and `exclude` are mutually exclusive"
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
var tabSyncImpl = (initializer, options) => (set, get, api) => {
|
|
27
|
+
validateOptions(options);
|
|
28
|
+
let sync_instance = null;
|
|
29
|
+
let is_remote_update = false;
|
|
30
|
+
const initial_state = initializer(set, get, api);
|
|
31
|
+
const syncable_initial = extractSyncableState(initial_state, options);
|
|
32
|
+
sync_instance = createTabSync({
|
|
33
|
+
initial: syncable_initial,
|
|
34
|
+
channel: options?.channel ?? "tab-sync-zustand",
|
|
35
|
+
debug: options?.debug,
|
|
36
|
+
transport: options?.transport,
|
|
37
|
+
merge: options?.merge,
|
|
38
|
+
onError: options?.onError
|
|
39
|
+
});
|
|
40
|
+
api.subscribe((next_state, prev_state) => {
|
|
41
|
+
if (is_remote_update || !sync_instance) return;
|
|
42
|
+
const next = next_state;
|
|
43
|
+
const prev = prev_state;
|
|
44
|
+
const diff = {};
|
|
45
|
+
let has_diff = false;
|
|
46
|
+
for (const key of Object.keys(next)) {
|
|
47
|
+
if (shouldSyncKey(key, next[key], options) && !Object.is(prev[key], next[key])) {
|
|
48
|
+
diff[key] = next[key];
|
|
49
|
+
has_diff = true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (has_diff) {
|
|
53
|
+
sync_instance.patch(diff);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
sync_instance.onChange((remote_state, changed_keys, meta) => {
|
|
57
|
+
if (meta.isLocal) return;
|
|
58
|
+
is_remote_update = true;
|
|
59
|
+
try {
|
|
60
|
+
const patch = {};
|
|
61
|
+
let has_patch = false;
|
|
62
|
+
for (const key of changed_keys) {
|
|
63
|
+
patch[key] = remote_state[key];
|
|
64
|
+
has_patch = true;
|
|
65
|
+
}
|
|
66
|
+
if (has_patch) {
|
|
67
|
+
api.setState(patch);
|
|
68
|
+
}
|
|
69
|
+
} finally {
|
|
70
|
+
is_remote_update = false;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
options?.onSyncReady?.(sync_instance);
|
|
74
|
+
return initial_state;
|
|
75
|
+
};
|
|
76
|
+
var tabSync = tabSyncImpl;
|
|
77
|
+
|
|
78
|
+
export { tabSync };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tab-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Zero-dependency TypeScript library for real-time state synchronization across browser tabs, with leader election and cross-tab RPC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -26,6 +26,16 @@
|
|
|
26
26
|
"types": "./dist/react/index.d.cts",
|
|
27
27
|
"default": "./dist/react/index.cjs"
|
|
28
28
|
}
|
|
29
|
+
},
|
|
30
|
+
"./zustand": {
|
|
31
|
+
"import": {
|
|
32
|
+
"types": "./dist/zustand/index.d.ts",
|
|
33
|
+
"default": "./dist/zustand/index.js"
|
|
34
|
+
},
|
|
35
|
+
"require": {
|
|
36
|
+
"types": "./dist/zustand/index.d.cts",
|
|
37
|
+
"default": "./dist/zustand/index.cjs"
|
|
38
|
+
}
|
|
29
39
|
}
|
|
30
40
|
},
|
|
31
41
|
"files": [
|
|
@@ -70,11 +80,15 @@
|
|
|
70
80
|
},
|
|
71
81
|
"sideEffects": false,
|
|
72
82
|
"peerDependencies": {
|
|
73
|
-
"react": ">=18.0.0"
|
|
83
|
+
"react": ">=18.0.0",
|
|
84
|
+
"zustand": ">=4.0.0"
|
|
74
85
|
},
|
|
75
86
|
"peerDependenciesMeta": {
|
|
76
87
|
"react": {
|
|
77
88
|
"optional": true
|
|
89
|
+
},
|
|
90
|
+
"zustand": {
|
|
91
|
+
"optional": true
|
|
78
92
|
}
|
|
79
93
|
},
|
|
80
94
|
"devDependencies": {
|
|
@@ -86,6 +100,7 @@
|
|
|
86
100
|
"react-dom": "^19.2.4",
|
|
87
101
|
"tsup": "^8.5.1",
|
|
88
102
|
"typescript": "^5.9.3",
|
|
89
|
-
"vitest": "^2.1.9"
|
|
103
|
+
"vitest": "^2.1.9",
|
|
104
|
+
"zustand": "^5.0.11"
|
|
90
105
|
}
|
|
91
106
|
}
|