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.
- package/LICENSE +21 -0
- package/README.md +834 -0
- package/dist/chunk-42VOZR6E.js +1309 -0
- package/dist/chunk-BQCNBNBT.cjs +1332 -0
- package/dist/index.cjs +149 -0
- package/dist/index.d.cts +304 -0
- package/dist/index.d.ts +304 -0
- package/dist/index.js +59 -0
- package/dist/react/index.cjs +174 -0
- package/dist/react/index.d.cts +61 -0
- package/dist/react/index.d.ts +61 -0
- package/dist/react/index.js +167 -0
- package/dist/types-BtK4ixKz.d.cts +306 -0
- package/dist/types-BtK4ixKz.d.ts +306 -0
- package/package.json +90 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
declare const PROTOCOL_VERSION = 1;
|
|
2
|
+
interface StateUpdatePayload {
|
|
3
|
+
entries: Record<string, {
|
|
4
|
+
value: unknown;
|
|
5
|
+
timestamp: number;
|
|
6
|
+
}>;
|
|
7
|
+
}
|
|
8
|
+
interface StateSyncResponsePayload {
|
|
9
|
+
state: Record<string, {
|
|
10
|
+
value: unknown;
|
|
11
|
+
timestamp: number;
|
|
12
|
+
}>;
|
|
13
|
+
}
|
|
14
|
+
interface LeaderClaimPayload {
|
|
15
|
+
createdAt: number;
|
|
16
|
+
}
|
|
17
|
+
interface TabAnnouncePayload {
|
|
18
|
+
createdAt: number;
|
|
19
|
+
isActive: boolean;
|
|
20
|
+
url: string;
|
|
21
|
+
title?: string;
|
|
22
|
+
}
|
|
23
|
+
interface RpcRequestPayload {
|
|
24
|
+
callId: string;
|
|
25
|
+
method: string;
|
|
26
|
+
args: unknown;
|
|
27
|
+
}
|
|
28
|
+
interface RpcResponsePayload {
|
|
29
|
+
callId: string;
|
|
30
|
+
result?: unknown;
|
|
31
|
+
error?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Maps every message type to its exact payload shape.
|
|
35
|
+
* Adding a new message only requires extending this interface.
|
|
36
|
+
*/
|
|
37
|
+
interface MessagePayloadMap {
|
|
38
|
+
STATE_UPDATE: StateUpdatePayload;
|
|
39
|
+
STATE_SYNC_REQUEST: null;
|
|
40
|
+
STATE_SYNC_RESPONSE: StateSyncResponsePayload;
|
|
41
|
+
LEADER_CLAIM: LeaderClaimPayload;
|
|
42
|
+
LEADER_ACK: null;
|
|
43
|
+
LEADER_HEARTBEAT: null;
|
|
44
|
+
LEADER_RESIGN: null;
|
|
45
|
+
TAB_ANNOUNCE: TabAnnouncePayload;
|
|
46
|
+
TAB_GOODBYE: null;
|
|
47
|
+
RPC_REQUEST: RpcRequestPayload;
|
|
48
|
+
RPC_RESPONSE: RpcResponsePayload;
|
|
49
|
+
}
|
|
50
|
+
type MessageType = keyof MessagePayloadMap;
|
|
51
|
+
/**
|
|
52
|
+
* Discriminated union of all inter-tab messages.
|
|
53
|
+
*
|
|
54
|
+
* Checking `msg.type` narrows `msg.payload` to the exact payload type,
|
|
55
|
+
* eliminating the need for runtime casts:
|
|
56
|
+
*
|
|
57
|
+
* ```ts
|
|
58
|
+
* if (msg.type === 'STATE_UPDATE') {
|
|
59
|
+
* msg.payload.entries; // ← StateUpdatePayload, fully typed
|
|
60
|
+
* }
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
type TabMessage = {
|
|
64
|
+
[K in MessageType]: {
|
|
65
|
+
readonly type: K;
|
|
66
|
+
readonly senderId: string;
|
|
67
|
+
readonly targetId?: string;
|
|
68
|
+
readonly timestamp: number;
|
|
69
|
+
readonly version?: number;
|
|
70
|
+
readonly payload: MessagePayloadMap[K];
|
|
71
|
+
};
|
|
72
|
+
}[MessageType];
|
|
73
|
+
/** Extract a single message variant by its type discriminant. */
|
|
74
|
+
type MessageOf<T extends MessageType> = Extract<TabMessage, {
|
|
75
|
+
type: T;
|
|
76
|
+
}>;
|
|
77
|
+
/** Function that sends a message through the transport channel. */
|
|
78
|
+
type SendFn = (message: TabMessage) => void;
|
|
79
|
+
interface TabInfo {
|
|
80
|
+
id: string;
|
|
81
|
+
createdAt: number;
|
|
82
|
+
lastSeen: number;
|
|
83
|
+
isLeader: boolean;
|
|
84
|
+
isActive: boolean;
|
|
85
|
+
url: string;
|
|
86
|
+
title?: string;
|
|
87
|
+
}
|
|
88
|
+
interface ChangeMeta {
|
|
89
|
+
readonly sourceTabId: string;
|
|
90
|
+
readonly isLocal: boolean;
|
|
91
|
+
readonly timestamp: number;
|
|
92
|
+
}
|
|
93
|
+
interface MiddlewareContext<TState extends Record<string, unknown>> {
|
|
94
|
+
readonly key: keyof TState;
|
|
95
|
+
readonly value: unknown;
|
|
96
|
+
readonly previousValue: unknown;
|
|
97
|
+
readonly meta: ChangeMeta;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Return `false` to reject the change, `{ value }` to transform it,
|
|
101
|
+
* or `void`/`undefined` to pass through unchanged.
|
|
102
|
+
*/
|
|
103
|
+
type MiddlewareResult = {
|
|
104
|
+
value: unknown;
|
|
105
|
+
} | false;
|
|
106
|
+
interface Middleware<TState extends Record<string, unknown> = Record<string, unknown>> {
|
|
107
|
+
readonly name: string;
|
|
108
|
+
/** Intercept local `set` / `patch` calls before they are applied. */
|
|
109
|
+
onSet?: (ctx: MiddlewareContext<TState>) => MiddlewareResult | void;
|
|
110
|
+
/** Called after any state change (local or remote) has been committed. */
|
|
111
|
+
afterChange?: (key: keyof TState, value: unknown, meta: ChangeMeta) => void;
|
|
112
|
+
/** Cleanup when the instance is destroyed. */
|
|
113
|
+
onDestroy?: () => void;
|
|
114
|
+
}
|
|
115
|
+
interface PersistOptions<TState extends Record<string, unknown> = Record<string, unknown>> {
|
|
116
|
+
/** Storage key. Default: `'tab-sync:state'` */
|
|
117
|
+
key?: string;
|
|
118
|
+
/** Only persist these keys (whitelist). */
|
|
119
|
+
include?: (keyof TState)[];
|
|
120
|
+
/** Exclude these keys from persistence (blacklist). */
|
|
121
|
+
exclude?: (keyof TState)[];
|
|
122
|
+
/** Custom serializer. Default: `JSON.stringify` */
|
|
123
|
+
serialize?: (state: Partial<TState>) => string;
|
|
124
|
+
/** Custom deserializer. Default: `JSON.parse` */
|
|
125
|
+
deserialize?: (raw: string) => Partial<TState>;
|
|
126
|
+
/** Debounce persistence writes in ms. Default: `100` */
|
|
127
|
+
debounce?: number;
|
|
128
|
+
/** Custom storage backend. Default: `localStorage` */
|
|
129
|
+
storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Define your RPC contract for full type inference:
|
|
133
|
+
*
|
|
134
|
+
* ```ts
|
|
135
|
+
* interface MyRPC {
|
|
136
|
+
* getTime: { args: void; result: { iso: string } };
|
|
137
|
+
* add: { args: { a: number; b: number }; result: number };
|
|
138
|
+
* }
|
|
139
|
+
*
|
|
140
|
+
* const sync = createTabSync<State, MyRPC>({ ... });
|
|
141
|
+
* const { iso } = await sync.call('leader', 'getTime'); // fully typed
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
type RPCMap = Record<string, {
|
|
145
|
+
args: unknown;
|
|
146
|
+
result: unknown;
|
|
147
|
+
}>;
|
|
148
|
+
/** Resolve args type for a method. Falls back to `unknown` for unregistered methods. */
|
|
149
|
+
type RPCArgs<TMap extends RPCMap, M extends string> = M extends keyof TMap ? TMap[M]['args'] : unknown;
|
|
150
|
+
/** Resolve result type for a method. Falls back to `unknown` for unregistered methods. */
|
|
151
|
+
type RPCResult<TMap extends RPCMap, M extends string> = M extends keyof TMap ? TMap[M]['result'] : unknown;
|
|
152
|
+
interface LeaderOptions {
|
|
153
|
+
/** Heartbeat interval in ms. Default: `2000` */
|
|
154
|
+
heartbeatInterval?: number;
|
|
155
|
+
/** Leader timeout in ms. Default: `6000` */
|
|
156
|
+
leaderTimeout?: number;
|
|
157
|
+
}
|
|
158
|
+
interface TabSyncOptions<TState extends Record<string, unknown> = Record<string, unknown>> {
|
|
159
|
+
/** Initial state used before sync completes. */
|
|
160
|
+
initial?: TState;
|
|
161
|
+
/** Channel name — only tabs sharing the same name communicate. Default: `'tab-sync'` */
|
|
162
|
+
channel?: string;
|
|
163
|
+
/** Force a specific transport layer. Default: auto-detect. */
|
|
164
|
+
transport?: 'broadcast-channel' | 'local-storage';
|
|
165
|
+
/** Custom merge function for LWW conflict resolution. */
|
|
166
|
+
merge?: (localValue: unknown, remoteValue: unknown, key: keyof TState) => unknown;
|
|
167
|
+
/** Enable leader election. Default: `true` */
|
|
168
|
+
leader?: boolean | LeaderOptions;
|
|
169
|
+
/** Heartbeat interval in ms. Default: `2000` */
|
|
170
|
+
heartbeatInterval?: number;
|
|
171
|
+
/** Leader timeout in ms. Default: `6000` */
|
|
172
|
+
leaderTimeout?: number;
|
|
173
|
+
/** Enable debug logging. Default: `false` */
|
|
174
|
+
debug?: boolean;
|
|
175
|
+
/** Persist state across page reloads. `true` uses defaults, or pass options. */
|
|
176
|
+
persist?: PersistOptions<TState> | boolean;
|
|
177
|
+
/** Middleware pipeline for intercepting state changes. */
|
|
178
|
+
middlewares?: Middleware<TState>[];
|
|
179
|
+
/** Error callback for non-fatal errors (storage, channel, etc.). */
|
|
180
|
+
onError?: (error: Error) => void;
|
|
181
|
+
}
|
|
182
|
+
interface TabSyncInstance<TState extends Record<string, unknown>, TRPCMap extends RPCMap = RPCMap> {
|
|
183
|
+
/** Read a single value by key. */
|
|
184
|
+
get<K extends keyof TState>(key: K): TState[K];
|
|
185
|
+
/**
|
|
186
|
+
* Read the entire state as a frozen snapshot.
|
|
187
|
+
* Returns the same reference until state changes (safe for React).
|
|
188
|
+
*/
|
|
189
|
+
getAll(): Readonly<TState>;
|
|
190
|
+
/** Update a single key. Synced to all tabs. */
|
|
191
|
+
set<K extends keyof TState>(key: K, value: TState[K]): void;
|
|
192
|
+
/** Update multiple keys in a single broadcast. */
|
|
193
|
+
patch(partial: Partial<TState>): void;
|
|
194
|
+
/**
|
|
195
|
+
* Subscribe to changes for a specific key.
|
|
196
|
+
* @returns Unsubscribe function.
|
|
197
|
+
*
|
|
198
|
+
* ```ts
|
|
199
|
+
* const off = sync.on('theme', (value, meta) => {
|
|
200
|
+
* console.log(value, meta.isLocal ? 'local' : 'remote');
|
|
201
|
+
* });
|
|
202
|
+
* off(); // unsubscribe
|
|
203
|
+
* ```
|
|
204
|
+
*/
|
|
205
|
+
on<K extends keyof TState>(key: K, callback: (value: TState[K], meta: ChangeMeta) => void): () => void;
|
|
206
|
+
/**
|
|
207
|
+
* Subscribe to a specific key, but fire only once then auto-unsubscribe.
|
|
208
|
+
*
|
|
209
|
+
* ```ts
|
|
210
|
+
* sync.once('theme', (value) => console.log('First change:', value));
|
|
211
|
+
* ```
|
|
212
|
+
*/
|
|
213
|
+
once<K extends keyof TState>(key: K, callback: (value: TState[K], meta: ChangeMeta) => void): () => void;
|
|
214
|
+
/**
|
|
215
|
+
* Subscribe to all state changes.
|
|
216
|
+
* @returns Unsubscribe function.
|
|
217
|
+
*/
|
|
218
|
+
onChange(callback: (state: Readonly<TState>, changedKeys: (keyof TState)[], meta: ChangeMeta) => void): () => void;
|
|
219
|
+
/**
|
|
220
|
+
* Subscribe to a **derived value**. The callback only fires when the
|
|
221
|
+
* selector's return value actually changes (compared via `isEqual`,
|
|
222
|
+
* default `Object.is`).
|
|
223
|
+
*
|
|
224
|
+
* ```ts
|
|
225
|
+
* sync.select(
|
|
226
|
+
* (s) => s.items.filter(i => i.done).length,
|
|
227
|
+
* (doneCount) => badge.textContent = doneCount,
|
|
228
|
+
* );
|
|
229
|
+
* ```
|
|
230
|
+
*
|
|
231
|
+
* @returns Unsubscribe function.
|
|
232
|
+
*/
|
|
233
|
+
select<TResult>(selector: (state: Readonly<TState>) => TResult, callback: (result: TResult, meta: ChangeMeta) => void, isEqual?: (a: TResult, b: TResult) => boolean): () => void;
|
|
234
|
+
/** Whether this tab is currently the leader. */
|
|
235
|
+
isLeader(): boolean;
|
|
236
|
+
/**
|
|
237
|
+
* Register a callback that fires when this tab becomes leader.
|
|
238
|
+
* Optionally return a cleanup function that runs when leadership is lost.
|
|
239
|
+
*
|
|
240
|
+
* ```ts
|
|
241
|
+
* sync.onLeader(() => {
|
|
242
|
+
* const ws = new WebSocket('...');
|
|
243
|
+
* return () => ws.close(); // cleanup on resign
|
|
244
|
+
* });
|
|
245
|
+
* ```
|
|
246
|
+
*/
|
|
247
|
+
onLeader(callback: () => void | (() => void)): () => void;
|
|
248
|
+
/** Get info about the current leader tab, or `null` if no leader yet. */
|
|
249
|
+
getLeader(): TabInfo | null;
|
|
250
|
+
/**
|
|
251
|
+
* Returns a promise that resolves with the leader's `TabInfo`
|
|
252
|
+
* as soon as a leader is elected. Resolves immediately if a leader
|
|
253
|
+
* already exists.
|
|
254
|
+
*
|
|
255
|
+
* ```ts
|
|
256
|
+
* const leader = await sync.waitForLeader();
|
|
257
|
+
* const result = await sync.call('leader', 'getData');
|
|
258
|
+
* ```
|
|
259
|
+
*/
|
|
260
|
+
waitForLeader(): Promise<TabInfo>;
|
|
261
|
+
/** Unique ID of this tab (UUID v4). */
|
|
262
|
+
readonly id: string;
|
|
263
|
+
/** List of all currently active tabs. */
|
|
264
|
+
getTabs(): TabInfo[];
|
|
265
|
+
/** Number of currently active tabs. */
|
|
266
|
+
getTabCount(): number;
|
|
267
|
+
/**
|
|
268
|
+
* Subscribe to tab presence changes (join, leave, leader change).
|
|
269
|
+
* @returns Unsubscribe function.
|
|
270
|
+
*/
|
|
271
|
+
onTabChange(callback: (tabs: TabInfo[]) => void): () => void;
|
|
272
|
+
/**
|
|
273
|
+
* Call a remote procedure on another tab.
|
|
274
|
+
*
|
|
275
|
+
* ```ts
|
|
276
|
+
* const result = await sync.call('leader', 'getTime');
|
|
277
|
+
* const sum = await sync.call(tabId, 'add', { a: 1, b: 2 });
|
|
278
|
+
* ```
|
|
279
|
+
*
|
|
280
|
+
* @param target - Tab ID or `'leader'` to auto-resolve.
|
|
281
|
+
* @param method - Method name (typed if `TRPCMap` is provided).
|
|
282
|
+
* @param args - Arguments to pass to the handler.
|
|
283
|
+
* @param timeout - Timeout in ms. Default: `5000`.
|
|
284
|
+
*/
|
|
285
|
+
call<M extends string>(target: string | 'leader', method: M, args?: RPCArgs<TRPCMap, M>, timeout?: number): Promise<RPCResult<TRPCMap, M>>;
|
|
286
|
+
/**
|
|
287
|
+
* Register an RPC handler that other tabs can call.
|
|
288
|
+
*
|
|
289
|
+
* ```ts
|
|
290
|
+
* sync.handle('add', ({ a, b }) => a + b);
|
|
291
|
+
* ```
|
|
292
|
+
*
|
|
293
|
+
* @returns Unsubscribe function to remove the handler.
|
|
294
|
+
*/
|
|
295
|
+
handle<M extends string>(method: M, handler: (args: RPCArgs<TRPCMap, M>, callerTabId: string) => RPCResult<TRPCMap, M> | Promise<RPCResult<TRPCMap, M>>): () => void;
|
|
296
|
+
/**
|
|
297
|
+
* Destroy this instance. Sends goodbye to other tabs,
|
|
298
|
+
* cancels all timers, and flushes pending state.
|
|
299
|
+
* Safe to call multiple times.
|
|
300
|
+
*/
|
|
301
|
+
destroy(): void;
|
|
302
|
+
/** `false` after `destroy()` has been called. */
|
|
303
|
+
readonly ready: boolean;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export { type ChangeMeta as C, type LeaderClaimPayload as L, type Middleware as M, PROTOCOL_VERSION as P, type RPCMap as R, type SendFn as S, type TabSyncOptions as T, type TabSyncInstance as a, type TabMessage as b, type TabInfo as c, type MiddlewareContext as d, type MessageType as e, type MessagePayloadMap as f, type MessageOf as g, type LeaderOptions as h, type MiddlewareResult as i, type PersistOptions as j, type RPCArgs as k, type RPCResult as l, type RpcRequestPayload as m, type RpcResponsePayload as n, type StateSyncResponsePayload as o, type StateUpdatePayload as p, type TabAnnouncePayload as q };
|
package/package.json
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tab-bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-dependency TypeScript library for real-time state synchronization across browser tabs, with leader election and cross-tab RPC",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"./react": {
|
|
21
|
+
"import": {
|
|
22
|
+
"types": "./dist/react/index.d.ts",
|
|
23
|
+
"default": "./dist/react/index.js"
|
|
24
|
+
},
|
|
25
|
+
"require": {
|
|
26
|
+
"types": "./dist/react/index.d.cts",
|
|
27
|
+
"default": "./dist/react/index.cjs"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsup",
|
|
36
|
+
"dev": "tsup --watch",
|
|
37
|
+
"test": "vitest run",
|
|
38
|
+
"test:watch": "vitest",
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
41
|
+
"demo": "npm run build && npx serve ."
|
|
42
|
+
},
|
|
43
|
+
"keywords": [
|
|
44
|
+
"tab-bridge",
|
|
45
|
+
"broadcast-channel",
|
|
46
|
+
"cross-tab",
|
|
47
|
+
"state-sync",
|
|
48
|
+
"leader-election",
|
|
49
|
+
"browser-tabs",
|
|
50
|
+
"rpc",
|
|
51
|
+
"multi-tab",
|
|
52
|
+
"localstorage",
|
|
53
|
+
"tab-sync",
|
|
54
|
+
"tab-communication",
|
|
55
|
+
"shared-state",
|
|
56
|
+
"realtime",
|
|
57
|
+
"typescript-library",
|
|
58
|
+
"react-hooks",
|
|
59
|
+
"zero-dependency"
|
|
60
|
+
],
|
|
61
|
+
"author": "serbi2012",
|
|
62
|
+
"license": "MIT",
|
|
63
|
+
"repository": {
|
|
64
|
+
"type": "git",
|
|
65
|
+
"url": "git+https://github.com/serbi2012/tab-sync.git"
|
|
66
|
+
},
|
|
67
|
+
"homepage": "https://github.com/serbi2012/tab-sync#readme",
|
|
68
|
+
"bugs": {
|
|
69
|
+
"url": "https://github.com/serbi2012/tab-sync/issues"
|
|
70
|
+
},
|
|
71
|
+
"sideEffects": false,
|
|
72
|
+
"peerDependencies": {
|
|
73
|
+
"react": ">=18.0.0"
|
|
74
|
+
},
|
|
75
|
+
"peerDependenciesMeta": {
|
|
76
|
+
"react": {
|
|
77
|
+
"optional": true
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"devDependencies": {
|
|
81
|
+
"@types/react": "^19.2.14",
|
|
82
|
+
"@types/react-dom": "^19.2.3",
|
|
83
|
+
"jsdom": "^25.0.1",
|
|
84
|
+
"react": "^19.2.4",
|
|
85
|
+
"react-dom": "^19.2.4",
|
|
86
|
+
"tsup": "^8.5.1",
|
|
87
|
+
"typescript": "^5.9.3",
|
|
88
|
+
"vitest": "^2.1.9"
|
|
89
|
+
}
|
|
90
|
+
}
|