tab-bridge 0.2.0 → 0.4.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,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.2.0",
3
+ "version": "0.4.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,36 @@
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
+ }
39
+ },
40
+ "./jotai": {
41
+ "import": {
42
+ "types": "./dist/jotai/index.d.ts",
43
+ "default": "./dist/jotai/index.js"
44
+ },
45
+ "require": {
46
+ "types": "./dist/jotai/index.d.cts",
47
+ "default": "./dist/jotai/index.cjs"
48
+ }
49
+ },
50
+ "./redux": {
51
+ "import": {
52
+ "types": "./dist/redux/index.d.ts",
53
+ "default": "./dist/redux/index.js"
54
+ },
55
+ "require": {
56
+ "types": "./dist/redux/index.d.cts",
57
+ "default": "./dist/redux/index.cjs"
58
+ }
29
59
  }
30
60
  },
31
61
  "files": [
@@ -38,6 +68,7 @@
38
68
  "test:watch": "vitest",
39
69
  "typecheck": "tsc --noEmit",
40
70
  "prepublishOnly": "npm run typecheck && npm test && npm run build",
71
+ "test:e2e": "playwright test",
41
72
  "demo": "npm run build && npx serve ."
42
73
  },
43
74
  "keywords": [
@@ -70,22 +101,40 @@
70
101
  },
71
102
  "sideEffects": false,
72
103
  "peerDependencies": {
73
- "react": ">=18.0.0"
104
+ "jotai": ">=2.0.0",
105
+ "react": ">=18.0.0",
106
+ "redux": ">=4.0.0",
107
+ "zustand": ">=4.0.0"
74
108
  },
75
109
  "peerDependenciesMeta": {
76
110
  "react": {
77
111
  "optional": true
112
+ },
113
+ "zustand": {
114
+ "optional": true
115
+ },
116
+ "jotai": {
117
+ "optional": true
118
+ },
119
+ "redux": {
120
+ "optional": true
78
121
  }
79
122
  },
80
123
  "devDependencies": {
124
+ "@playwright/test": "^1.58.2",
125
+ "@reduxjs/toolkit": "^2.11.2",
81
126
  "@types/react": "^19.2.14",
82
127
  "@types/react-dom": "^19.2.3",
83
128
  "@vitest/coverage-v8": "^2.1.9",
129
+ "jotai": "^2.18.0",
84
130
  "jsdom": "^25.0.1",
85
131
  "react": "^19.2.4",
86
132
  "react-dom": "^19.2.4",
133
+ "redux": "^5.0.1",
134
+ "serve": "^14.2.6",
87
135
  "tsup": "^8.5.1",
88
136
  "typescript": "^5.9.3",
89
- "vitest": "^2.1.9"
137
+ "vitest": "^2.1.9",
138
+ "zustand": "^5.0.11"
90
139
  }
91
140
  }